feat(agent): агент логов — фильтр, LLM-страж, планировщик grep (#18)
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:
2026-10-17 15:02:07 +03:00
co-authored by Claude Opus 5.5
parent f89338e015
commit 65acf3abf1
20 changed files with 917 additions and 26 deletions
+4
View File
@@ -0,0 +1,4 @@
"""Log agent: prefilter → LLM guard → LLM planner → validation → grep (ARCHITECTURE §2.8, ADR-007, ADR-013).
Logs never reach the LLM: the planner sees only the user's question and the container names.
"""
+38
View File
@@ -0,0 +1,38 @@
"""Step 2 (SPEC L-11): a separate, narrow LLM call — is the question an attack? Anything but a valid `safe` is one."""
from dataclasses import dataclass
from app.db.models import Verdict
from app.llm.provider import ChatMessage, JsonSchema, LLMProvider
SCHEMA = JsonSchema(
name="guard_verdict",
schema={
"type": "object",
"additionalProperties": False,
"required": ["verdict", "reason"],
"properties": {
"verdict": {"type": "string", "enum": ["safe", "attack"]},
"reason": {"type": "string"},
},
},
)
INVALID = "страж вернул не JSON по схеме"
@dataclass(frozen=True)
class GuardVerdict:
verdict: Verdict
reason: str
tokens: int
async def classify(llm: LLMProvider, system_prompt: str, prompt: str) -> GuardVerdict:
"""LLMError propagates: an unreachable model is «try later», not an attack."""
result = await llm.complete([ChatMessage("system", system_prompt), ChatMessage("user", prompt)], SCHEMA)
data = result.data or {}
verdict, reason = data.get("verdict"), data.get("reason")
if verdict not in ("safe", "attack") or not isinstance(reason, str):
return GuardVerdict(Verdict.ATTACK, INVALID, result.tokens)
return GuardVerdict(Verdict(verdict), reason[:300], result.tokens)
+86
View File
@@ -0,0 +1,86 @@
"""Step 4 (SPEC L-13, L-14): the only thing the planner may produce, and its validation.
Even if the guard is fooled, the worst a planner reply can do is a slow grep over a managed container's logs.
"""
from typing import Any
import regex
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
from app.logs.grep import MAX_PATTERN, GrepQuery
MAX_MINUTES = 7 * 24 * 60
# Nested quantifiers and quantified alternations — the classic catastrophic-backtracking shapes: (a+)+, (a|aa)*
_NESTED = regex.compile(r"\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)\s*[+*{]")
_ALT_REPEAT = regex.compile(r"\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)\s*[+*{]")
def risky_backtracking(pattern: str) -> bool:
return bool(_NESTED.search(pattern) or _ALT_REPEAT.search(pattern))
class AgentLogQuery(BaseModel):
"""Validated with `context={"containers": [...]}` — the whitelist of names."""
model_config = ConfigDict(extra="forbid", frozen=True)
container: str
since_minutes: int = Field(ge=1, le=MAX_MINUTES)
pattern: str = Field(min_length=1, max_length=MAX_PATTERN)
ignore_case: bool = True
invert: bool = False
count_only: bool = False
context: int = Field(default=0, ge=0, le=5)
limit: int = Field(default=100, ge=1, le=500)
@field_validator("container")
@classmethod
def _whitelisted(cls, value: str, info: ValidationInfo) -> str:
allowed = (info.context or {}).get("containers", [])
if value not in allowed:
raise ValueError("container is not in the list")
return value
@field_validator("pattern")
@classmethod
def _safe_regex(cls, value: str) -> str:
try:
regex.compile(value)
except regex.error as e:
raise ValueError(f"pattern does not compile: {e}") from e
if risky_backtracking(value):
raise ValueError("pattern may backtrack catastrophically")
return value
def to_grep(self) -> GrepQuery:
return GrepQuery(
pattern=self.pattern,
ignore_case=self.ignore_case,
invert=self.invert,
extended=True,
count=self.count_only,
context=self.context,
)
def json_schema() -> dict[str, Any]:
"""Strict structured-output schema for the planner: every field required, no extras.
Bounds are left to the pydantic model — strict mode of several providers rejects minimum/maxLength."""
types = {
"container": "string",
"since_minutes": "integer",
"pattern": "string",
"ignore_case": "boolean",
"invert": "boolean",
"count_only": "boolean",
"context": "integer",
"limit": "integer",
}
return {
"type": "object",
"additionalProperties": False,
"required": list(types),
"properties": {name: {"type": kind} for name, kind in types.items()},
}
+45
View File
@@ -0,0 +1,45 @@
"""Step 3 (SPEC L-13, L-16): the LLM turns the question into an `AgentLogQuery`. It never sees the logs."""
from dataclasses import dataclass
from datetime import datetime
from pydantic import ValidationError
from app.agent.log_query import AgentLogQuery, json_schema
from app.llm.provider import ChatMessage, JsonSchema, LLMProvider
SCHEMA = JsonSchema(name="log_query", schema=json_schema())
@dataclass(frozen=True)
class Plan:
query: AgentLogQuery | None # None — the reply didn't pass validation
error: str
tokens: int
def system_prompt(template: str, containers: list[str], default: str, now: datetime) -> str:
# str.replace, not format: the template contains JSON braces
return (
template.replace("{containers}", ", ".join(containers))
.replace("{default}", default)
.replace("{now}", now.strftime("%Y-%m-%d %H:%M"))
)
async def plan(
llm: LLMProvider, template: str, prompt: str, containers: list[str], default: str, now: datetime
) -> Plan:
messages = [ChatMessage("system", system_prompt(template, containers, default, now)), ChatMessage("user", prompt)]
result = await llm.complete(messages, SCHEMA)
if result.data is None:
return Plan(None, "not JSON", result.tokens)
try:
query = AgentLogQuery.model_validate(result.data, context={"containers": containers})
except ValidationError as e:
return Plan(
None,
"; ".join(f"{'.'.join(map(str, err['loc']))}: {err['msg']}" for err in e.errors())[:300],
result.tokens,
)
return Plan(query, "", result.tokens)
+53
View File
@@ -0,0 +1,53 @@
"""Step 1 (SPEC L-10): deterministic rules from `config/agent_rules.yaml`, before any LLM call."""
from pathlib import Path
import regex
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
class Rule(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
reason: str
pattern: str
@field_validator("pattern")
@classmethod
def _compiles(cls, value: str) -> str:
try:
regex.compile(value)
except regex.error as e:
raise ValueError(f"bad pattern: {e}") from e
return value
class AgentRules(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
max_length: int = Field(default=500, gt=0)
rules: list[Rule]
def load_rules(config_dir: Path) -> AgentRules:
with (config_dir / "agent_rules.yaml").open(encoding="utf-8") as f:
return AgentRules.model_validate(yaml.safe_load(f))
TOO_LONG = "слишком длинный запрос"
class Prefilter:
def __init__(self, rules: AgentRules) -> None:
self._max = rules.max_length
self._rules = [(r.reason, regex.compile(r.pattern, regex.IGNORECASE)) for r in rules.rules]
def check(self, prompt: str) -> str | None:
"""The reason to refuse, or None when the prompt may go on to the guard."""
if len(prompt) > self._max:
return TOO_LONG
for reason, pattern in self._rules:
if pattern.search(prompt, timeout=0.1):
return reason
return None
+120
View File
@@ -0,0 +1,120 @@
"""The agent pipeline end to end, with the daily quota and the `agent_requests` journal (SPEC L-10…L-17)."""
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import StrEnum
from pathlib import Path
from sqlalchemy import func, select
from app.agent import guard, planner
from app.agent.log_query import AgentLogQuery
from app.agent.prefilter import Prefilter, load_rules
from app.db.models import AgentRequest, Verdict, utcnow
from app.db.session import SessionMaker
from app.llm.provider import LLMError, LLMProvider
log = logging.getLogger(__name__)
DAILY_QUOTA = 30 # L-17
class Outcome(StrEnum):
OK = "ok"
ATTACK = "attack" # prefilter or guard: refuse politely, alert the admins
QUOTA = "quota"
UNAVAILABLE = "unavailable" # the LLM is down
NOT_UNDERSTOOD = "not_understood" # the planner's reply failed validation
@dataclass(frozen=True)
class Answer:
outcome: Outcome
query: AgentLogQuery | None = None
reason: str = "" # for the admins' alert and the journal; never shown to the asker
stage: str = "" # prefilter | guard
@dataclass(frozen=True)
class Prompts:
guard: str
planner: str
def load_prompts(config_dir: Path) -> Prompts:
d = config_dir / "prompts"
return Prompts(
guard=(d / "guard.md").read_text(encoding="utf-8"), planner=(d / "planner.md").read_text(encoding="utf-8")
)
class Agent:
def __init__(
self, llm: LLMProvider, sessions: SessionMaker, prefilter: Prefilter, prompts: Prompts, quota: int = DAILY_QUOTA
) -> None:
self._llm = llm
self._sessions = sessions
self._prefilter = prefilter
self._prompts = prompts
self.quota = quota
@classmethod
def from_config(cls, llm: LLMProvider, sessions: SessionMaker, config_dir: Path) -> "Agent":
return cls(llm, sessions, Prefilter(load_rules(config_dir)), load_prompts(config_dir))
async def used_today(self, user_id: int, now: datetime) -> int:
async with self._sessions() as session:
count = await session.scalar(
select(func.count())
.select_from(AgentRequest)
.where(AgentRequest.user_id == user_id, AgentRequest.at >= now - timedelta(days=1))
)
return int(count or 0)
async def ask(self, user_id: int, prompt: str, containers: list[str], default: str) -> Answer:
now = utcnow()
prompt = prompt.strip()
if await self.used_today(user_id, now) >= self.quota:
return Answer(Outcome.QUOTA)
reason = self._prefilter.check(prompt)
if reason is not None:
await self._journal(user_id, prompt, Verdict.ATTACK, f"prefilter: {reason}", None, 0)
return Answer(Outcome.ATTACK, reason=reason, stage="prefilter")
tokens = 0
try:
verdict = await guard.classify(self._llm, self._prompts.guard, prompt)
tokens += verdict.tokens
if verdict.verdict is not Verdict.SAFE:
await self._journal(user_id, prompt, Verdict.ATTACK, f"guard: {verdict.reason}", None, tokens)
return Answer(Outcome.ATTACK, reason=verdict.reason, stage="guard")
result = await planner.plan(self._llm, self._prompts.planner, prompt, containers, default, now)
tokens += result.tokens
except LLMError as e:
log.warning("agent: llm unavailable: %s", e)
await self._journal(user_id, prompt, Verdict.ERROR, f"llm: {str(e)[:200]}", None, tokens)
return Answer(Outcome.UNAVAILABLE)
if result.query is None:
await self._journal(user_id, prompt, Verdict.ERROR, f"planner: {result.error}", None, tokens)
return Answer(Outcome.NOT_UNDERSTOOD, reason=result.error)
await self._journal(user_id, prompt, Verdict.SAFE, None, result.query.model_dump(), tokens)
return Answer(Outcome.OK, query=result.query)
async def _journal(
self,
user_id: int,
prompt: str,
verdict: Verdict,
reason: str | None,
query: dict[str, object] | None,
tokens: int,
) -> None:
async with self._sessions.begin() as session:
session.add(
AgentRequest(
user_id=user_id, prompt=prompt, verdict=verdict, reason=reason, log_query=query, tokens=tokens
)
)
+116 -18
View File
@@ -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
+12 -3
View File
@@ -9,6 +9,7 @@ from aiogram.client.session.aiohttp import AiohttpSession
from app import __version__
from app.access.service import AccessService
from app.agent.service import Agent
from app.bot.fsm import make_storage
from app.bot.middlewares.access import AccessMiddleware
from app.bot.routers import commit, common, logs, members, status
@@ -17,6 +18,7 @@ from app.db.maintenance import cleanup_loop
from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker
from app.health import heartbeat
from app.llm.factory import make_provider
from app.logs.journal import LogJournal
from app.logs_setup import setup_logging
from app.repo.drafts import DraftService
@@ -38,6 +40,7 @@ def build_dispatcher(
commit_url_template: str = "",
docker: DockerClient | None = None,
checker: Checker | None = None,
agent: Agent | None = None,
) -> Dispatcher:
dp = Dispatcher(storage=make_storage(sessions))
gate = AccessMiddleware(access, texts)
@@ -49,7 +52,7 @@ def build_dispatcher(
commit.build_router(texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template)
)
dp.include_router(status.build_router(texts, docker, checker))
dp.include_router(logs.build_router(texts, docker, LogJournal(sessions)))
dp.include_router(logs.build_router(texts, docker, LogJournal(sessions), agent, access))
dp.include_router(common.build_router(texts, access))
return dp
@@ -66,8 +69,11 @@ async def run(settings: Settings) -> None:
docker = DockerClient(settings.docker) if settings.docker.url else None
store = SnapshotStore(sessions)
checker = Checker(docker, store, checks_cfg) if docker else None
# The mock provider answers `{}` to the guard — every question would be an «attack»: no real LLM, no agent
llm = make_provider(settings.llm) if settings.llm.provider != "mock" else None
agent = Agent.from_config(llm, sessions, settings.runtime.config_dir) if llm else None
dp = build_dispatcher(
texts, sessions, access, UploadsRepo(settings.git), settings.git.commit_url_template, docker, checker
texts, sessions, access, UploadsRepo(settings.git), settings.git.commit_url_template, docker, checker, agent
)
rt = settings.runtime
background = [
@@ -80,11 +86,12 @@ async def run(settings: Settings) -> None:
session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None
bot = Bot(settings.telegram.bot_token.get_secret_value(), session=session)
log.info(
"tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s",
"tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s, agent %s",
__version__,
len(settings.access.admin_ids),
"via proxy" if proxy else "direct",
"on" if docker else "off",
settings.llm.provider if agent else "off",
)
try:
await dp.start_polling(bot, handle_signals=True)
@@ -99,6 +106,8 @@ async def run(settings: Settings) -> None:
await docker.aclose()
if checker:
await checker.aclose()
if llm:
await llm.aclose()
await engine.dispose()
log.info("tgit stopped")
+9 -1
View File
@@ -162,7 +162,15 @@ class LogsTexts(_Strict):
hours: str
grep_button: str
agent_button: str
agent_soon: str
agent_ask: str
agent_off: str
agent_thinking: str
agent_refused: str
agent_quota: str
agent_unavailable: str
agent_not_understood: str
head_agent: str
attack_alert: str
to_list: str
to_container: str
ask_grep: str