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:
- Read the
Authorization: Bearer <jwt>header. - Call
admin.auth.getUser(jwt)— this validates the signature and expiry against Supabase, not just that a token-shaped string was present. - Look up the
peoplerow whereauth_user_idmatches the verified Supabase user id. - Return
nullif any step fails, including "authenticated but not a provisioned person" — a valid Supabase login with no matchingpeoplerow 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— meaninganoncould call them over/rest/v1/rpc. Nothing leaked (they return only the caller's ownpeoplerow, andanonhas noauth.uid()), but the grant was broader than any caller needed.revoke execute ... from public, anonplus an explicitgrant execute ... to authenticated, service_rolenarrowed 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 theartifactsowner-read policy inlinedauth.uid()in a subquery, which Postgres's planner re-evaluates for every row scanned (the advisor'sauth_rls_initplanfinding). 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/masterbranch - recursive
rm(-r/-R/--recursive, in any flag ordering) - reads of credential/secret paths —
.env*,.pem/id_rsa*,.ssh/.aws/.hscli/secretsdirectories,hubspot.config.yml,.npmrc, anything with "credential" in the filename — whether read directly, throughcat/less/grep/awk/sed/cp/scp/rsync, through agitreader (diff/show/log/grep/blame/cat-file) with a rev:path operand likeHEAD:.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— theSABLE_PROMPT_OWNERgate.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.