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:
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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()},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user