Corteksa

AI Security Model

AI Security Model

This is the reference for reviewing the security of the v2 AI stack. It documents the trust boundaries (what the system trusts, what it does not, and where the line is enforced) and the tool capability matrix (every tool the AI can call, what it can do, and how each call is authorized). Read it before changing any tool, prompt, or enforcement path — and use the checklist so a new capability can't ship without a security review.

The core principle: the AI is advisory, the backend is authoritative

The single most important security property of this design is architectural, not prompt-based:

The model can request an action. It can never perform one. Every effect the AI has on data passes through the same server-side controls a human request does — authentication, tenant isolation, authorization, and (for writes) an explicit human confirmation. The model's output is a proposal; the backend is the final authority.

This is why prompt hardening (identity pinning, injection rules) is the weakest layer here, not the strongest: even a fully jailbroken or prompt-injected model produces only tool-call requests, and each request is independently re-authorized in code that never trusts the model. A compromised prompt cannot read another tenant's data, cannot exceed the caller's own permissions, and cannot silently delete anything.

Trust zones

ZoneExamplesTrustConsequence
System-authoredAgent system prompts, identity.prompt.ts, security.prompt.ts, the tool policy registryTrustedThe only source of instructions the system obeys.
Signed-in user (typed)The admin's chat messageSemi-trustedDrives intent, but every resulting action is still authorized against that admin's permissions. Cannot escalate.
Model outputAssistant text, tool-call names + argsUntrustedArgs are zod-validated at the tool boundary; the tool name is looked up in a fail-closed policy registry; the call is re-authorized before it runs.
Tool-result dataRecord fields, comments, messaging contact names, lead-ad form fields, imported rowsUntrusted (outsider-authored)Re-enters the model as role:'tool' data. Instruction-shaped text inside it is data, never a command (indirect-injection rule in security.prompt.ts).
Request bodyconversationId, client-echoed planUntrustedthread_id is derived server-side from auth context, never from the body (thread-id.util.ts); a forged/cross-tenant slug returns zero rows under RLS.

The four authoritative controls

Every AI-initiated effect crosses all four. None of them consults the model.

  1. Authentication. The acting admin is established from the JWT before any turn runs — the web gateway's ws-auth and the WhatsApp listener both resolve a real AdminUser; the model never asserts identity. The AI even refuses to claim an engine identity (identity.prompt.ts).

  2. Tenant isolation. On the shared hyper DB, every AI turn runs inside a workspace-pinned RLS scope (TenantScope.runInScope, entered by the gateway's scopedRun and the listener before any DB access). The LangGraph thread_id is tenantDatabase:workspaceId:conversationSlug, derived server-side — so a guessed or cross-tenant conversation slug resolves to no checkpoint row. See ISOLATION.md for the RLS + pinning contract.

    Within one workspace, RLS does not separate two admins — the thread_id carries no admin — so conversation ownership is enforced in application code: AiGraphService.assertOwnConversation refuses any turn (run, stream, and both confirm resumes) whose conversation index row belongs to a different admin, returning 404 rather than 403. It sits at the front door, so REST and WebSocket are covered by the one check. A slug with no index row is allowed — that is a first turn minting its own conversation. The REST reads (GET /:slug, /messages, clear, delete, feedback) assert the same ownership individually.

  3. Authorization. PolicyGuard runs at the tool-execution boundary, before the tool touches data, answering purely from the auth snapshot already on the request (no DB round-trip). It is fail-closed: a tool with no entry in tool-policy.registry.ts is denied. Object tools require the caller's {verb}.{slug} route name (the A/G/M/D level ≠ Denied); capability tools require a binary feature permission.

  4. Confirmation gate (human-in-the-loop). For destructive/mutating calls the graph pauses at an interrupt and the user must approve (agent.graph.ts confirmWrites). Underneath it, GuardedToolExecutor is a fail-closed backstop: a call that needs confirmation never runs unless its exact tool_call_id is in the approved set — so even a routing/graph bug cannot write on a plain model-issued call. An over-large batch (e.g. too many deletes) is declined before the user is even asked (rejectBatch).

Two supporting properties:

  • Idempotency. WRITE/DANGER results are keyed so a resume replays a prior successful write instead of double-applying it. SAFE reads skip this.
  • Row-level scoping. Past the coarse permission gate, DataService still filters every query by the caller's A/G/M/D scope (A = all, G = group, M = mine, D = none) — the AI reads exactly the rows the same admin would through the REST API.

Risk tiers

Each tool is classified by RiskTier, independent of who calls it:

TierMeaningIdempotency-keyedConfirmation
SAFEReads / side-effect-free meta toolsNoNo
WRITECreates / updatesYesYes, in a per-write run (the Units agent); builder writes are confirmed at the plan-review gate
DANGERDeletes / destructive / cross-object movesYesAlways — never auto-runs, in any mode

Tool capability matrix

The authoritative source is tool-policy.registry.ts; this table is its human-readable projection. Authorization is the coarse gate the PolicyGuard applies; object tools are additionally row-scoped inside DataService. Agents: U = Units (schema-manager), B = Workspace Builder.

Reads — SAFE (no confirmation)

ToolAgentAuthorization
query_dataUobject read.{slug}
count_recordsUobject read.{slug}
group_by_fieldUobject read.{slug}
get_object_fieldsUobject read.{slug}
get_object_relationsUobject read.{slug}
get_calculation_infoUobject read.{slug}
smart_catalog_browseUobject read.{slug}
list_viewsUobject read.{slug}
get_table_configUobject read.{slug}
get_record_historyUobject read.{slug}
list_commentsUobject read.{slug}
preview_record_moveUobject move.{slug} (read-only analysis)
list_objectsUexempt — filtered in-tool to objects the caller can read.{slug}
list_tasksUexempt — workspace-scoped, RLS-enforced
list_adminsUexempt — no per-object gate on listing teammates
ask_questionsBexempt — intercepted by the discovery gate
propose_planBexempt — intercepted by the plan-review gate

Writes — WRITE (confirmed)

ToolAgentAuthorization
create_recordUobject create.{slug}
update_recordUobject update.{slug}
bulk_update_recordsUobject update.{slug} (capped MAX_AI_BULK_RECORDS)
bulk_assign_recordsUobject update.{slug}
create_taskUobject create.task (+ update.task re-checked in-tool when assigning others)
assign_taskUobject update.task
toggle_task_statusUexempt — workspace-scoped
create_commentUobject read.{slug} (annotate what you can see)
reply_to_commentUexempt — object read level enforced in-tool on the resolved comment
create_objectU, Bcapability object.create
create_fieldU, Bcapability object.create
create_relationU, Bcapability relation.create
create_viewU, Bcapability object.create
create_folderU, Bcapability object.create
create_automationBcapability workflow.create (built via the plan-review gate)
seed_sample_dataBobject create.{slug}
update_fieldUcapability object.update
update_objectUcapability object.update
update_relationUcapability relation.update
toggle_field_statusUcapability object.update
toggle_object_statusUcapability object.activate
toggle_relation_statusUcapability relation.update
update_viewUobject update.{slug}
update_table_configUobject update.{slug}
delete_viewUobject delete.{slug} (view config only — no record data)

Destructive — DANGER (always confirmed, never auto-runs)

ToolAgentAuthorization
delete_recordUobject delete.{slug}
bulk_delete_recordsUobject delete.{slug} (capped MAX_AI_DELETES_PER_TURN)
move_recordUobject move.{slug}
delete_objectUcapability object.delete (drops the object + all its records)
delete_fieldUcapability object.delete (drops the column + all its values)
delete_commentUexempt — object read level enforced in-tool

Super-admins bypass the permission check (PolicyGuard), but not tenant isolation or the confirmation gate — a super-admin still only ever acts inside the pinned workspace and still confirms every destructive write.

Adding a new tool safely

A tool with no policy entry is denied at runtime, and the completeness guardrail spec fails CI — so classification is mandatory, not optional. To add a tool:

  1. Classify it in tool-policy.registry.ts: pick the kind (object / capability / exempt) and RiskTier (SAFE / WRITE / DANGER). Reads are SAFE; anything that mutates is WRITE; anything destructive or irreversible-by-the-model is DANGER.
  2. Object tools must supply verb + objectFrom so the guard can derive the {verb}.{slug} route name. Capability tools must name the feature permission.
  3. Validate args with zod in the tool's build() schema — this is the boundary that stops malformed/injected args (e.g. a non-enum sort_direction) from reaching a query. Never cast model args without validating them.
  4. Never build raw SQL from an arg. Route through the existing services (DataService, the schema builder) so identifiers stay whitelist-sanitized and values stay parameterized.
  5. Add the completeness/guardrail tests and confirm SAFE≠idempotent, WRITE/DANGER are gated.

If the tool needs a capability that doesn't map to an existing permission, add the permission first — do not widen an existing tool to cover a new action.

Residual risks & non-goals

This model bounds actions in server code; a few softer surfaces remain (tracked in the security audit):

  • Indirect prompt injection is currently defended by a prose rule only — there is no structural spotlighting/fence around tool-result data. Impact is capped by the confirmation gate (an injected instruction still can't write unconfirmed), but a <untrusted_data> fence around ToolMessage content is the recommended hardening.
  • Unicode normalization is not yet applied to user input, so homoglyph/zero-width variants can evade the literal-string injection rules. NFKC-normalize before text enters the prompt.
  • The v1 engine (pre-LangGraph) does not zod-validate tool args and should be disabled once the web frontend has cut over to v2 — v2 is the model documented here.
  • Cost / denial-of-wallet: credits are reserved before the model runs, tool-call loops are capped per turn, every tool result is capped at TOOL_RESULT_CAP_BYTES in GuardedToolExecutor (the one choke point, so no tool can write an unbounded payload into the transcript), and the prompt itself is windowed to HISTORY_MAX_TURNS recent user turns (windowHistory). A long thread therefore no longer grows its prompt without bound. The window applies to what the MODEL sees only — the checkpoint and the transcript endpoint still hold the full conversation, so the user loses nothing. Per-turn cost is logged (turn agent=… prompt=…) so the window size can be tuned against real numbers.

These are hardening opportunities, not breaks in the trust boundary: the authoritative controls above hold regardless.

On this page