Skip to main content

Security Posture

SABLE's security model has four layers: Supabase Auth + JWT verification, row-level security (RLS) in Postgres, per-route rate limiting, and a local Claude Code guard hook that blocks a class of destructive commands before they run. None of these is a sandbox against a deliberately adversarial actor — they are defense-in-depth against mistakes, and against a compromised or careless client.

Identity: Supabase Auth and the person mapping

Every live API route that needs identity calls viewer(req) from web/api/_auth.js:

  1. Read the Authorization: Bearer <jwt> header.
  2. Call admin.auth.getUser(jwt) — this validates the signature and expiry against Supabase, not just that a token-shaped string was present.
  3. Look up the people row where auth_user_id matches the verified Supabase user id.
  4. Return null if any step fails, including "authenticated but not a provisioned person" — a valid Supabase login with no matching people row is treated as no session.

Scope is always derived from this verified identity server-side, never from a client-supplied ?as= parameter — routes that let a lead view another person's scope re-derive that person from the database, they do not trust a query string naming who to impersonate.

Two roles matter almost everywhere: admin and lead (together, "lead access") can see across the whole org and reach admin-gated routes; everyone else is scoped to their own projects and client memberships.

Row-level security

Nearly every table's RLS policy resolves to one of four STABLE SECURITY DEFINER helper functions: current_org_id(), current_person(), current_person_role(), and can_see_project(uuid). They exist as SECURITY DEFINER because they read people on behalf of users who cannot read that table directly — switching them to INVOKER would break every policy that calls them.

Two hardening passes are worth knowing about, both from the 2026-09-09 Supabase advisor audit:

  • Execute grants were tightened. The helper functions were, by Postgres default, executable by PUBLIC — meaning anon could call them over /rest/v1/rpc. Nothing leaked (they return only the caller's own people row, and anon has no auth.uid()), but the grant was broader than any caller needed. revoke execute ... from public, anon plus an explicit grant execute ... to authenticated, service_role narrowed it without touching the 96 policies that call these functions.
  • Three policies were rewritten to stop re-evaluating auth.uid() per row. calendar_busy, calendar_availability_status, and the artifacts owner-read policy inlined auth.uid() in a subquery, which Postgres's planner re-evaluates for every row scanned (the advisor's auth_rls_initplan finding). Routing them through the same STABLE helpers every other policy already uses lets the planner evaluate the predicate once.

Two other advisor suggestions were deliberately not applied: moving the vector extension out of public (one function pins search_path to pg_catalog, public and would stop resolving the vector type) and relocating pg_net (it is not relocatable). Namespace hygiene was judged not worth a retrieval outage.

Rate limiting

web/api/_rate_limit.js implements a simple in-memory token-bucket limiter (createRateLimiter({ limit, windowMs })) keyed by rateLimitKey(req, viewer, scope) — normally the authenticated person's id, falling back to "anonymous" for unauthenticated callers. Several routes have their own environment-configurable budget, so an operator can raise or lower a specific route's limit without a code change: SABLE_CHAT_RATE_LIMIT, SABLE_CHAT_STREAM_RATE_LIMIT, SABLE_SEARCH_RATE_LIMIT, SABLE_INGEST_RATE_LIMIT, SABLE_EXA_RATE_LIMIT, SABLE_FEEDBACK_RATE_LIMIT, SABLE_PROMPT_IMPORT_RATE_LIMIT, SABLE_PARITY_RATE_LIMIT, SABLE_TRIGGER_CHAT_TOKEN_RATE_LIMIT. Because the limiter is in-memory, it resets on every serverless cold start — it is a per-instance guardrail against runaway loops and abuse, not a durable global quota.

Prompt control's own gate

Promoting or rolling back the live SABLE agent prompt, and approving learned prompt rules, is gated independently of the general admin/lead check by SABLE_PROMPT_OWNER — a comma-separated allowlist of emails (Lindsey Cutts, and Kirk Orrick since 2026-09-05). It must be set identically on Vercel and on the Supabase Edge environment; a mismatch is a real control gap, not just an inconvenience, since either side enforcing a different owner list defeats the point of naming one. Unset, it falls back to "any admin/lead" — so setting it is what actually narrows the gate.

The local guard hook

sable-agents-demo/.claude/hooks/guard.py is a PreToolUse hook for Claude Code that hard-blocks a specific set of destructive Bash invocations before they execute, independent of the allow/ask/deny rules in .claude/settings.json:

  • force or mirror push (--force, -f, --force-with-lease, --force-if-includes, --mirror, or a +refspec)
  • git reset --hard
  • destructive git clean (force plus -d/-x/-X)
  • force-deleting the main/master branch
  • recursive rm (-r/-R/--recursive, in any flag ordering)
  • reads of credential/secret paths — .env*, .pem/id_rsa*, .ssh/.aws/.hscli/secrets directories, hubspot.config.yml, .npmrc, anything with "credential" in the filename — whether read directly, through cat/less/grep/awk/sed/cp/scp/rsync, through a git reader (diff/show/log/grep/blame/cat-file) with a rev:path operand like HEAD:.env, or through input redirection (< .env)

It also unwraps a sudo/doas prefix, a leading VAR=value environment assignment, a $(...)/backtick command substitution, an unquoted heredoc body, and ( )/{ } shell grouping before checking the underlying command — so bash -c "rm -rf /" inside a substitution is still caught. Its own docstring is explicit about what is not covered: generic launchers (sh -c, env, nice, xargs, find -exec) hiding a command, eval, base64 | sh, write-then-run scripts, aliases, variable indirection, and exotic quoting. Exit code 0 means "no objection" — normal allow/ask/deny still applies on top, nothing is auto-approved by passing the guard.

Run npm run doctor:claude from the repository root to confirm the hook is actually wired into your session's Claude Code configuration before relying on it.

Secrets handling

Track secret names, never secret values — in code, docs, issues, chat, or an AI session. The environment manifests in env-manifests/*.json record which variables exist, where, and why, with no values. Production connector secrets live in Supabase Edge secrets (and, for a handful of scheduler-facing values, the Supabase Vault); web/Vercel secrets live in the Vercel dashboard. Nothing is committed to the repository.

Where the code lives

  • sable-agents-demo/web/api/_auth.js — JWT verification and person resolution.
  • sable-agents-demo/web/api/_rate_limit.js — the rate limiter and its key derivation.
  • sable-agents-demo/web/api/_prompt_owner.js — the SABLE_PROMPT_OWNER gate.
  • sable-agents-demo/supabase/migrations/20260909190000_lock_rls_helpers_and_initplan_policies.sql.
  • sable-agents-demo/.claude/hooks/guard.py, sable-agents-demo/.claude/settings.json.