Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
5.0 KiB
5.0 KiB
09 — Security & performance: auto-reply + AI fallback
Agent: 9/10 · Scope: harden FAQ match + AI fallback (no git)
Contract: 02-contract.md §§6–7
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 | RedactSecretsForMatch → security.RedactSecrets |
| AI rate limiter | apps/api/internal/support/auto_ratelimit.go |
| Prompt builder (isolation) | apps/api/internal/support/auto_prompt.go → BuildAutoReplyMessages |
| 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
context.WithTimeout(..., AutoReplyTimeout)- Load
company_idfor ticket (fail closed if no pool) SupportAI == nil→ErrAIAutoReplyDisabled(product still opt-in; agent 4 wires runner)AIRateLimiter.Allow(companyID)→ elseErrAIRateLimitedClaimAutoReplyAttempt(FOR UPDATE; already-posted / disabled / closed)SupportAI.RunAutoReplywith nested timeout; errors logged viaRedactForAutoLog
Prompt contract
- Fixed
AutoReplySystemPrompt(server-owned; not admin free-text). - Customer text wrapped in
<<<UNTRUSTED_*_START/END>>>afterSanitizeUntrustedTicketText. - Platform KB only (
Company == uuid.Nil) or matchingcompanyID; never other tenants’ tickets.
Idempotency
- Application:
PostMatchedAutoReply+ClaimAutoReplyAttempt/InsertAutoSystemMessagecheckauto_reply_message_id, status, and existing publicis_auto_replyrows; unique violations treated as soft skip. - DB: unique partial index
support_messages_one_public_auto_per_ticket_uidxon(ticket_id) WHERE is_auto_reply AND NOT is_internal_note. - AI jobs: unique partial
support_auto_jobs_ticket_active_uidxin034_support_auto_jobs.sqlforpending|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
- Platform KB has no
company_id(global help center). Per-tenant KB remains out of scope; if added later, every query must filtercompany_idand prompt builders must keep usingFilterKBSnippetsForCompany. - Agent 4 implements
SupportAIRunnerusingBuildAutoReplyMessages+InsertAutoSystemMessage+aiprovider.ResolveCompleterForRole(..., RoleSupport)— no second secret store. - 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.