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.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# 09 — Security & performance: auto-reply + AI fallback
|
||||
|
||||
**Agent:** 9/10 · **Scope:** harden FAQ match + AI fallback (no git)
|
||||
**Contract:** [02-contract.md](./02-contract.md) §§6–7
|
||||
**Related:** [01-inventory.md](./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
|
||||
|
||||
1. `context.WithTimeout(..., AutoReplyTimeout)`
|
||||
2. Load `company_id` for ticket (fail closed if no pool)
|
||||
3. **`SupportAI == nil` → `ErrAIAutoReplyDisabled`** (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
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user