All documentation

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 to AdminCapability

(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_news in

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).