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
| Zone | Examples | Trust | Consequence |
|---|---|---|---|
| System-authored | Agent system prompts, identity.prompt.ts, security.prompt.ts, the tool policy registry | Trusted | The only source of instructions the system obeys. |
| Signed-in user (typed) | The admin's chat message | Semi-trusted | Drives intent, but every resulting action is still authorized against that admin's permissions. Cannot escalate. |
| Model output | Assistant text, tool-call names + args | Untrusted | Args 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 data | Record fields, comments, messaging contact names, lead-ad form fields, imported rows | Untrusted (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 body | conversationId, client-echoed plan | Untrusted | thread_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.
-
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). -
Tenant isolation. On the shared hyper DB, every AI turn runs inside a workspace-pinned RLS scope (
TenantScope.runInScope, entered by the gateway'sscopedRunand the listener before any DB access). The LangGraphthread_idistenantDatabase: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_idcarries no admin — so conversation ownership is enforced in application code:AiGraphService.assertOwnConversationrefuses 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. -
Authorization.
PolicyGuardruns 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 intool-policy.registry.tsis 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. -
Confirmation gate (human-in-the-loop). For destructive/mutating calls the graph pauses at an
interruptand the user must approve (agent.graph.tsconfirmWrites). Underneath it,GuardedToolExecutoris a fail-closed backstop: a call that needs confirmation never runs unless its exacttool_call_idis 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,
DataServicestill 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:
| Tier | Meaning | Idempotency-keyed | Confirmation |
|---|---|---|---|
| SAFE | Reads / side-effect-free meta tools | No | No |
| WRITE | Creates / updates | Yes | Yes, in a per-write run (the Units agent); builder writes are confirmed at the plan-review gate |
| DANGER | Deletes / destructive / cross-object moves | Yes | Always — 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)
| Tool | Agent | Authorization |
|---|---|---|
query_data | U | object read.{slug} |
count_records | U | object read.{slug} |
group_by_field | U | object read.{slug} |
get_object_fields | U | object read.{slug} |
get_object_relations | U | object read.{slug} |
get_calculation_info | U | object read.{slug} |
smart_catalog_browse | U | object read.{slug} |
list_views | U | object read.{slug} |
get_table_config | U | object read.{slug} |
get_record_history | U | object read.{slug} |
list_comments | U | object read.{slug} |
preview_record_move | U | object move.{slug} (read-only analysis) |
list_objects | U | exempt — filtered in-tool to objects the caller can read.{slug} |
list_tasks | U | exempt — workspace-scoped, RLS-enforced |
list_admins | U | exempt — no per-object gate on listing teammates |
ask_questions | B | exempt — intercepted by the discovery gate |
propose_plan | B | exempt — intercepted by the plan-review gate |
Writes — WRITE (confirmed)
| Tool | Agent | Authorization |
|---|---|---|
create_record | U | object create.{slug} |
update_record | U | object update.{slug} |
bulk_update_records | U | object update.{slug} (capped MAX_AI_BULK_RECORDS) |
bulk_assign_records | U | object update.{slug} |
create_task | U | object create.task (+ update.task re-checked in-tool when assigning others) |
assign_task | U | object update.task |
toggle_task_status | U | exempt — workspace-scoped |
create_comment | U | object read.{slug} (annotate what you can see) |
reply_to_comment | U | exempt — object read level enforced in-tool on the resolved comment |
create_object | U, B | capability object.create |
create_field | U, B | capability object.create |
create_relation | U, B | capability relation.create |
create_view | U, B | capability object.create |
create_folder | U, B | capability object.create |
create_automation | B | capability workflow.create (built via the plan-review gate) |
seed_sample_data | B | object create.{slug} |
update_field | U | capability object.update |
update_object | U | capability object.update |
update_relation | U | capability relation.update |
toggle_field_status | U | capability object.update |
toggle_object_status | U | capability object.activate |
toggle_relation_status | U | capability relation.update |
update_view | U | object update.{slug} |
update_table_config | U | object update.{slug} |
delete_view | U | object delete.{slug} (view config only — no record data) |
Destructive — DANGER (always confirmed, never auto-runs)
| Tool | Agent | Authorization |
|---|---|---|
delete_record | U | object delete.{slug} |
bulk_delete_records | U | object delete.{slug} (capped MAX_AI_DELETES_PER_TURN) |
move_record | U | object move.{slug} |
delete_object | U | capability object.delete (drops the object + all its records) |
delete_field | U | capability object.delete (drops the column + all its values) |
delete_comment | U | exempt — 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:
- Classify it in
tool-policy.registry.ts: pick thekind(object/capability/exempt) andRiskTier(SAFE/WRITE/DANGER). Reads are SAFE; anything that mutates is WRITE; anything destructive or irreversible-by-the-model is DANGER. - Object tools must supply
verb+objectFromso the guard can derive the{verb}.{slug}route name. Capability tools must name the featurepermission. - Validate args with zod in the tool's
build()schema — this is the boundary that stops malformed/injected args (e.g. a non-enumsort_direction) from reaching a query. Never cast model args without validating them. - 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. - 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 aroundToolMessagecontent 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_BYTESinGuardedToolExecutor(the one choke point, so no tool can write an unbounded payload into the transcript), and the prompt itself is windowed toHISTORY_MAX_TURNSrecent 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.