AI Security
How the AI Platform protects conversation content, scopes RAG retrieval, and defends against prompt injection. Builds entirely on this codebase's existing security primitives (RLS, credential-crypto.ts, checkRateLimit(), logAuditEvent, src/lib/security/admin.ts) — no new security infrastructure was introduced.
Conversation encryption
ai_messages.content is encrypted at rest with the same AES-256-GCM helpers (encryptCredentials/decryptCredentials, src/lib/security/credential-crypto.ts) already used for broker credentials — reused as-is, not reimplemented. SupabaseConversationPersistence (services/supabase-conversation- persistence.ts) encrypts on write and decrypts on read, stamping is_encrypted per row (defense in depth: a row somehow flagged encrypted with unparseable content fails closed via safeDecrypt, never throws into the caller). The admin Conversations browser (/admin/ai/conversations) deliberately exposes metadata only — id, user, feature, message count, timestamps — and never decrypts content for admin viewing, respecting the encryption's own purpose.
RAG permission scoping
ai_embeddings.owner_user_id: null = platform-wide document (available to every RAG query), non-null = scoped to that one user's own trading data. Enforced twice:
1. Row-Level Security on ai_embeddings itself. 2. match_ai_embeddings() — the pgvector cosine-similarity RPC — is SECURITY DEFINER and reads auth.uid() internally, never from a caller-supplied user-id parameter. This is the same pattern as is_org_member()/org_role() elsewhere in this codebase: a SECURITY DEFINER function that trusts only the session's own JWT, not an argument the caller controls.
Critical invariant: AIRAGService.retrieve() must always be constructed with the caller's session-scoped Supabase client, never the service role. If it were called with the service role, auth.uid() inside match_ai_embeddings() would resolve to nothing and the permission check would (correctly, if confusingly) return zero rows rather than leak another user's data — still not a security bug, but wrong behavior, and every call site in this codebase (ChatService, the Chat route) passes the session client for exactly this reason.
Prompt-injection defense-in-depth
The primary defense is structural, not textual: every AI call in this codebase sends user-typed text as a role: 'user' message, distinct from the role: 'system' instructions built server-side (ChatService.streamReply(), PropFirmAssistantService.explainRule(), etc.). Every supported provider is trained to treat that role boundary as authoritative regardless of what the user's own text contains.
sanitizeUserInput() (src/lib/ai/security/sanitize.ts, pure, unit tested — tests/ai-sanitize.test.ts) is the second layer, applied at every free-text ingress point (chat messages, prop-firm questions, goal descriptions) before the text is persisted or sent to a provider:
- Strips C0 control characters and zero-width/bidi-override Unicode
characters (used to hide instructions from a human reviewer while a model still reads them) — keeps ordinary \t/\n/\r.
- Strips literal chat-template delimiters (
<|im_start|>,[INST],
<<SYS>>, ...) that a naive downstream consumer might parse as a turn boundary.
- Neutralizes a fake role label at the start of a line (e.g. `"System:
ignore all previous instructions"`) without mangling ordinary use of the word "system" mid-sentence.
- Caps input length at 8,000 characters (cost/DoS bound, complementary to
the per-hour checkAIRateLimit() cap below).
This is intentionally not a claim of "injection-proof" — no textual filter is — it narrows the attack surface for the cases the structural role boundary alone doesn't cover.
Rate limiting
checkAIRateLimit(feature, identifier) (shared/rate-limit.ts) reuses the existing checkRateLimit()/check_rate_limit() Postgres function with one bucket per AIFeature (ai_chat, ai_coach, ai_journal, ai_prop_firm, ai_risk_manager, ai_market, ai_automation, ai_embedding) — see docs/AI_COST_GUIDE.md for the actual per-hour limits. Zero new rate-limiting infrastructure.
Permissions
- User-scoped mutations — every dashboard AI Server Action checks
supabase.auth.getUser() explicitly (defense in depth) in addition to the owner-scoped RLS policy (using (auth.uid() = user_id)) that already protects every AI table with a user_id column (risk_automation_rules, ai_automations, ai_coaching_goals, ...). Two risk-manager and two automations actions were found during the Phase 10 review to be missing the explicit check (relying on RLS alone, which is safe but silently no-ops for an unauthenticated caller instead of returning a clear error) and were fixed for consistency with every other action in the codebase.
- Admin capability —
'manage_ai', added toAdminCapability
(src/lib/security/admin.ts), gated to admin/super_admin only, same matrix shape as manage_feature_flags. Guards prompt-template overrides today.
- Feature-plan gating —
ai_coach/ai_journal/ai_newsin
src/lib/billing/plans.ts's PlanLimits, enforced via checkFeatureAccess() — unchanged from docs/BILLING.md, reused as-is by every AI page and the /api/ai/chat route.
Audit logging
logAuditEvent() (existing, src/lib/security/audit-log.ts) is called on: every automated risk action (ai_risk_manager_lock_trading, ..._pause_copying, ..._reduce_risk, ..._notify — risk-manager/automation-service.ts) and admin prompt-template overrides (admin_ai_prompt_updated, added to the admin_actions category in src/lib/admin/audit-queries.ts so it appears under the Audit Center's existing "Admin Actions" filter).
What this milestone does NOT add
- No new encryption scheme — conversation encryption reuses
credential-crypto.ts verbatim.
- No content moderation / output filtering layer on AI-generated text —
out of scope for this milestone; every AI-generated insight/brief is labeled as AI-generated in its own prompt and in the UI copy that renders it.
- No per-organization AI access controls beyond the existing plan-tier
gates — "org-level AI access" from the original spec is satisfied by the existing checkFeatureAccess() gate, which is already organization-scoped (see docs/BILLING.md).