Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

5.0 KiB
Raw Permalink Blame History

09 — Security & performance: auto-reply + AI fallback

Agent: 9/10 · Scope: harden FAQ match + AI fallback (no git)
Contract: 02-contract.md §§67
Related: 01-inventory.md, docs/security-notes.md


Goal

Make automatic first responses (FAQ match → AI fallback) safe under abuse:

Control Requirement
Rate limits AI jobs: 10 / company / hour + 30 / platform / minute (FAQ match excluded)
Prompt injection Ticket subject/body treated as untrusted data (delimiters + soft filters)
Tenant isolation Ticket reads/writes always filter company_id; KB snippets with foreign Company dropped
Secret redaction Strip keys/tokens/PEM/DSNs before match features, prompts, and logs
Timeouts AutoReplyTimeout = 25s on TryAutoReplyLLM
Idempotency No double public auto-post (row lock + unique partial index)
Indexes See migration 035_support_auto_security_perf.sql

Implementation map

Piece Path
Secret redact + untrusted wrap apps/api/internal/security/ticket_prompt.go
FAQ match uses shared redact RedactSecretsForMatchsecurity.RedactSecrets
AI rate limiter apps/api/internal/support/auto_ratelimit.go
Prompt builder (isolation) apps/api/internal/support/auto_prompt.goBuildAutoReplyMessages
Idempotent claim / insert apps/api/internal/support/auto_idempotency.go
Gated LLM entry TryAutoReplyLLM in ai_auto_reply.go
Idempotency indexes apps/api/sql/schema/035_support_auto_security_perf.sql
AI jobs table apps/api/sql/schema/034_support_auto_jobs.sql (agent 4)
Abuse tests security/ticket_prompt_test.go, support/ai_auto_reply_test.go, support/auto_security_test.go

TryAutoReplyLLM gate order

  1. context.WithTimeout(..., AutoReplyTimeout)
  2. Load company_id for ticket (fail closed if no pool)
  3. SupportAI == nilErrAIAutoReplyDisabled (product still opt-in; agent 4 wires runner)
  4. AIRateLimiter.Allow(companyID) → else ErrAIRateLimited
  5. ClaimAutoReplyAttempt (FOR UPDATE; already-posted / disabled / closed)
  6. SupportAI.RunAutoReply with nested timeout; errors logged via RedactForAutoLog

Prompt contract

  • Fixed AutoReplySystemPrompt (server-owned; not admin free-text).
  • Customer text wrapped in <<<UNTRUSTED_*_START/END>>> after SanitizeUntrustedTicketText.
  • Platform KB only (Company == uuid.Nil) or matching companyID; never other tenants tickets.

Idempotency

  • Application: PostMatchedAutoReply + ClaimAutoReplyAttempt / InsertAutoSystemMessage check auto_reply_message_id, status, and existing public is_auto_reply rows; unique violations treated as soft skip.
  • DB: unique partial index support_messages_one_public_auto_per_ticket_uidx on (ticket_id) WHERE is_auto_reply AND NOT is_internal_note.
  • AI jobs: unique partial support_auto_jobs_ticket_active_uidx in 034_support_auto_jobs.sql for pending|running.

Threat model (abuse cases covered by tests)

Case Test
Injection phrases in ticket body TestSanitizeUntrustedTicketTextFiltersInjection, TestBuildAutoReplyMessages_treatsBodyAsUntrusted
API keys / Stripe / PEM / DSN in body TestRedactSecretsTicketAbuse, TestRedactSecretsForMatch_delegates
Cross-tenant KB snippet TestFilterKBSnippetsForCompany_blocksCrossTenant, TestBuildAutoReplyMessages_dropsForeignCompanySnippets
Rate limit company / platform TestAIRateLimiter_*
Secrets in log strings TestRedactForAutoLog_stripsSecrets
LLM disabled by default TestTryAutoReplyLLM_refuses

Performance notes

Path Guidance
FAQ match Sync, corpus cache (agent 3), GIN on keywords (032), no external I/O
AI Async preferred (jobs table in 033); HTTP create must not await LLM
Lists Do not embed full customer_context (agent 5)
Rate limits In-process only — effective RPM ≈ N × replicas (same as marketing/processing)

ASSUMPTIONs

  1. Platform KB has no company_id (global help center). Per-tenant KB remains out of scope; if added later, every query must filter company_id and prompt builders must keep using FilterKBSnippetsForCompany.
  2. Agent 4 implements SupportAIRunner using BuildAutoReplyMessages + InsertAutoSystemMessage + aiprovider.ResolveCompleterForRole(..., RoleSupport) — no second secret store.
  3. FAQ match stays free of AI rate limits (contract §6).

Verify

cd apps/api
go test ./internal/security ./internal/support -count=1 -run "Redact|SanitizeUntrusted|WrapUntrusted|AIRate|BuildAutoReply|FilterKB|TryAutoReplyLLM_refuses|AutoReplyTimeout"

Apply migrations through 035_support_auto_security_perf.sql (and 034_support_auto_jobs.sql) before relying on unique indexes / jobs table.

Rollback

Revert 035_* down migration; remove SupportAI / AIRateLimiter fields usage; restore stub-only TryAutoReplyLLM if needed. FAQ match continues to work without AI runner.