How GnamiAI is put together.
GnamiAI is a multi-tenant AI workspace. Several of the design choices below exist specifically because running an agent for strangers is a different threat model than running one for yourself. This page lists them honestly, including the limits.
Authentication
- Passwords are hashed with
scrypt(N=217, r=8, p=1, 32-byte output) and a 16-byte random salt per account. Verification is constant-time. A database leak would not yield passwords. - Session tokens are 32-byte random values. The server stores only the
SHA-256hash; the raw token lives only in your browser cookie. A database leak would not yield live sessions. - Session cookies are
HttpOnly,Secure,SameSite=Lax, and HMAC-signed. A forged cookie fails at the edge before any DB lookup. - Session lifetime — 30-day absolute cap, 7-day sliding idle cap. Beyond either, the row is deleted and you sign in again.
Key material at rest
Every provider API key you connect (OpenAI, Anthropic, OpenRouter,
Ollama, Mem0) is encrypted in the database with AES-256-GCM
using a 32-byte key encryption key held as a server environment
variable. Each key gets a fresh 12-byte IV and a 16-byte authentication
tag. Changing the KEK without access to the previous one makes every
stored key unrecoverable ciphertext — the operator backs it up.
The per-request HMAC key that signs your session cookies is also a 32-byte env var, separate from the KEK. Rotating it invalidates every outstanding session by design.
Tenant isolation
Every tenant-scoped table carries a tenant_id column.
Every non-admin API handler begins with requireTenant(),
which re-verifies your session against the DB and returns the tenant
record. Every subsequent query filters by that tenant id. Admin
endpoints are additionally gated by requireAdmin() which
re-reads the is_admin flag from your DB row on every call
— never from the session cookie.
A tampered client that submits another tenant's id cannot read or mutate that tenant's data. Server handlers ignore caller-supplied ids in the paths where it matters; the filter is always the session's tenant.
Multi-tenant guardrails
- Rate limit — 20 chat turns per minute per tenant. The count is read from your own budget ledger rows in the last 60 seconds; one tenant cannot consume another tenant's quota.
- Input cap — 8000 characters per chat turn, 250 KB per SKILL.md install fetch.
- Attachment caps — 5 files max per message, 2 MB per file, 3.5 MB total.
- Per-tenant resource caps — 50 skills, 20 subagents, 10 schedules.
SSRF protection
Two features accept URLs you supply: the Ollama integration (base URL) and skill-install-from-URL. Both run the URL through a guard that rejects:
- non-http(s) schemes (
file://,javascript:, etc.) - private IP literals (
10/8,127/8,172.16/12,192.168/16) - link-local and metadata-endpoint addresses (
169.254/16) - loopback, CGNAT, and IPv6 unique-local / link-local ranges
- reserved hostnames like
localhostand anything ending in.internal - URLs with credentials embedded (
user:pass@host)
DNS-based rebinding is a residual risk; we document it rather than pretend otherwise. For the maximum-security deployment, operators run the fetches through an egress proxy that resolves and pins IPs.
What GnamiAI explicitly cannot do
- No shell execution. Hosted GnamiAI never has and never will register a shell tool. This is a build-time decision, not a toggle.
- No filesystem access. The agent has no read/write access to the server's disk.
- No cross-tenant visibility. An agent running for tenant A cannot see tenant B's skills, memories, subagents, schedules, or provider keys.
- No admin impersonation. There is no "view as user" feature. Support must be text-based. The operator can see aggregate and billing information about tenants but not read their chats or decrypt their keys.
Content that reaches Postgres
Every text field that gets inserted into Postgres passes through a
sanitiser that strips \x00 bytes (which Postgres refuses in
TEXT columns) and normalises Windows line endings. This is
belt-and-braces: the guard above rejects obvious non-markdown content
at fetch time, and the sanitiser catches anything that slipped.
Scheduled runs
The endpoint that fires scheduled tasks (/api/cron/tick) is
protected by a server-only CRON_SECRET via an
Authorization: Bearer header. Without the correct secret,
the endpoint refuses to run. A public request cannot cause scheduled
work to execute on anyone's account.
Transport
The deployment runs on Vercel's edge with TLS 1.3. HSTS headers are served so browsers refuse to downgrade to HTTP on subsequent visits.
Responsible disclosure
Found something? Email the operator. Please don't exploit the finding against other tenants — that's a rule, not a suggestion. Reports that include a concrete reproduction get a response; vague speculation does not.