Security Considerations¶
Editions: OSS. Contributor documentation for this repository.
Auth, tenancy, redaction, and secret custody are platform concerns, not agent self-reporting. This chapter covers the security checklist, redaction policy, secret service, the tamper-evident audit chain and record signatures, security-screen scoring, and preloop.security.
Security Screen Scoring (QM Proxy Contract)¶
- Purpose: Let external agent platforms delegate content security screening to Preloop through a documented HTTP contract, starting with QM's
securityScreen: { backend: "proxy" }deployment option. - Endpoint:
POST /api/v1/security-screen/score(api/endpoints/security_screen.py) accepts{text, hook, metadata}with the caller's token inx-api-key(Bearer fallback) and returns{score, threshold, primary_outcome};primary_outcomeis omitted for benign content. Auth reuses the model gateway'sauthenticate_bearer_token, so a standard Preloop API key is the routed credential. - Scoring:
services/security_screen.pyis a pure, deterministic rule engine: compiled case-insensitive regex categories (prompt_injection,destructive_command,destructive_sql,secret_exfiltration) with max-match-wins scoring. No I/O, no model calls, no persistence on the scoring path; the threshold comes fromPRELOOP_SECURITY_SCREEN_THRESHOLD(default 0.7, clamped). - Privacy: Screened text is never logged or stored. Flagged chunks log score, outcome, matched rule names, and caller chunk coordinates only.
- Rollout Semantics: Shadow vs enforce and fail-closed error handling live on the caller's side (per QM's contract); Preloop only scores.
Response security headers¶
- Header set: The console nginx sends
X-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy: camera=(), geolocation=(), payment=(), usb=(), microphone=(self)(the talk composer records audio),Content-Security-Policyand, on HTTPS requests,Strict-Transport-Security, all withalwaysso error responses carry them too.X-XSS-Protectionis not sent: current browsers ignore it. - One definition per config: nginx drops server-level
add_headerdirectives in anylocationorifblock that declares its ownadd_header, which used to strip the set from the SPA shell, static assets,/adminand/api/. The set now lives infrontend/docker/security-headers.conf(Docker image, copied to/etc/nginx/snippets/) and thepreloop.nginxSecurityHeadersnamed template inhelm/preloop/templates/_helpers.tpl. Both configs include it at server level and in every block that sets headers.backend/tests/test_nginx_security_headers.pyenforces coverage and Docker/Helm parity;backend/tests/test_nginx_headers_smoke.py(opt-in,PRELOOP_NGINX_SMOKE=1) checks real nginx responses. - HSTS: An http-context
mapturnsX-Forwarded-Proto/$schemeinto$preloop_hsts, empty for plain HTTP or when disabled, so nginx omits the header. nginx hides upstream copies (proxy_hide_header) so proxied responses carry one.preloop.api.middleware.hsts.HSTSMiddleware, the outermost API/gateway middleware, adds the header on HTTPS requests that bypass the console and leaves an existing header alone. Settings and the preload guard are in TLS.
Console Content-Security-Policy¶
The console nginx replaces the old permissive policy (default-src 'self' http: https: data: blob: 'unsafe-inline', which allowed scripts from any origin and inline scripts) with explicit directives:
default-src 'self';
script-src 'self' {analytics origin} {extra script sources};
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:;
font-src 'self' data:;
connect-src 'self' ws: wss: data: {analytics origin} {extra connect sources};
media-src 'self' blob:;
worker-src 'self' blob:;
frame-src https://www.youtube-nocookie.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self'
- Why each exception:
style-src 'unsafe-inline'is needed by Lit components and inline style attributes.connect-src data:is needed because Shoelace fetches its built-in system icons asdata:URLs.frame-srcallows the privacy-enhanced video embeds on the landing page.media-srchas no remote hosts, so bloghero_videofiles must be served from the console's own origin (seefrontend/BLOG.md).script-srcnever contains'unsafe-inline'or a scheme source. - Modes:
CSP_MODE(Docker and Compose) orsecurityHeaders.csp.mode(Helm).report-only(default in this release): the legacy enforcingContent-Security-Policystays, and the new policy is sent asContent-Security-Policy-Report-Only. Browsers log violations of the new policy in the developer console and block nothing.enforce: only the new policy, asContent-Security-Policy.legacy: only the old header, as before. This is the rollback setting. Any other value stops the console container before nginx starts (frontend/docker/15-preloop-csp.envsh, sourced by the nginx entrypoint) and failshelm template. Both list the three valid values.
- Scope: The new policy applies to what nginx serves from the console build: the SPA shell, prerendered pages and assets. Responses proxied from the API (
/api/,/approval/,/oauth/,/invitations/acceptand the other proxied paths) keep the legacy header in every mode, because the API's own HTML pages are not covered by the console's violation sweep. The separate admin SPA under/admin(shipped by the enterprise image) keeps the legacy header for the same reason. Both need their own sweep before they get the new policy. API JSON responses and the docs site are out of scope. - Extra sources:
CSP_EXTRA_SCRIPT_SRC/securityHeaders.csp.extraScriptSrcandCSP_EXTRA_CONNECT_SRC/securityHeaders.csp.extraConnectSrctake space-separated host sources (https://cdn.example.com,*.example.com,127.0.0.1:4000) appended toscript-srcandconnect-src. Keywords, quotes, bare schemes such ashttps:, a bare*and anything else that is not a host source are refused at startup or render time. A browser error reporter enabled at build time (VITE_SENTRY_DSN) needs its ingest origin in the connect sources before switching toenforce. - Analytics: With
analytics.enabled, the Helm chart injects<script src="/analytics-queue.js">(a static file in the console build that queueswindow.plausibleevents) and the tracker tag, and adds the origin ofanalytics.scriptUrltoscript-srcandconnect-src. A relativescriptUrl(a tracker proxied under the console's own domain) adds nothing, since'self'covers it. A protocol-relative one adds its host. No inline script is injected. - Custom analytics snippet:
analytics.customSnippetis raw HTML the chart cannot inspect, so no origin is added for it. Operators using it must list its origins insecurityHeaders.csp.extraScriptSrc(andextraConnectSrcfor where it sends events). Inline code in the snippet is blocked underenforce; move it to a file served from a listed origin first. - No inline script in the console: the loading indicator (
/app-loading.js) and the brand configuration (a JSON data block read by/brand-config.js) are files. JSON-LD blocks are data and need no change. The console has no inline event handler attributes orjavascript:URLs. - Tests:
backend/tests/test_nginx_csp.py(Docker template and envsh),backend/tests/helm/test_nginx_csp_render.py(rendered Helm, Docker/Helm parity) and the opt-in real-nginxbackend/tests/test_nginx_headers_smoke.py.npm run test:cspinfrontend/builds the console with a SaaS fixture brand (frontend/tests/csp/) and loads every route of the console router, the landing page, a blog post, a/vs/page and a/resources/page under the enforced policy, with analytics off and on against a local stub tracker, failing on anysecuritypolicyviolationevent or CSP console error. - Next step: A later release flips the default to
enforceonce the sweep has been green onmainfor a release.
Secret Service¶
- Purpose: Provider-agnostic custody and resolution of model credentials.
- Built-in Backend:
local_encryptedfor encrypted-at-rest credentials stored in Preloop-managed storage. - External Backends: Optional Vault/OpenBao-compatible KV v2 references via
SecretReference.external_ref. - Runtime Boundary: Gateway-enabled runtimes receive Preloop gateway tokens instead of provider API keys.
- Rotation:
POST /api/v1/ai-models/{model_id}/credentials/rotatereplaces a model's API key (local_encrypted) or external reference (Vault/OpenBao) in place, so every model sharing the secret picks it up on its next request. New material is verified first (encryption round-trip, or a successful external read); on failure the old secret stays in force. Each replacement incrementsSecretReference.versionand setsrotated_at, shown on models ascredentials_versionandcredentials_rotated_at, and writes asecret_reference/rotatedaudit event without the material. Subscription OAuth bundles rotate through provider refresh instead.
User, mobile and runtime API keys store SHA-256 fingerprints and lookup prefixes;
issuance returns the token once. AI model keys and webhook secrets use account-owned
(or system-owned for global models) SecretReference rows. A backfill revision
clears the legacy plaintext columns; a later release removes them. See the
operator upgrade guide for
rolling-upgrade behaviour and compatible rollback versions.
preloop.security (./backend/preloop/security)¶
- Purpose: Deterministic, server-side validation of security audit results. It is result validation, not scanning.
- Gap-Register Freeze:
gap_register.pyvalidates theresult.jsonproduced by release security audit runs. Previous SHA+path finding rows are a floor: dropping one without aresolvedmarker plus a reason fails, unclassified rows fail, andsecrets_findings_countmust match the row count. The agent never self-grades the floor. - Git Guard:
git_guard.pyallow-lists metadata-only git invocations so no historical blob contents can be dumped into logs or transcripts. It has no production callers yet; it is the enforcement half of the planned follow-up that wiresvalidate_gap_registerinto result ingestion. - Waiver Inputs:
waivers.pydeterministically factors human-authored waiver entries ({id, reason, author, date}, plus optional scope/expiry/package/version and the platform approval id for interactively collected ones) into a severity-gate outcome: alias-aware matching (a CVE id waives the same advisory surfaced under a GHSA/OSV alias), verbatim echo of applied entries, unmatched/invalid entries surfaced rather than dropped, and an unwaived failure always keeps the gate failed. Interactive006waivers bind storedask_usertool_result/responses(exact finding id and human reason), notstatus=approvedor question text. Ambiguousrequest_approvalprose, including awaive_findingoperation, is not a waiver. AI-judged and auto-approved rows cannot authenticate a human waiver or due-diligence decision. The severity gate is KEV or CVSS >= 9.0 unless trigger/CIgate.fail_on_kev/gate.fail_on_cvss_gte(in[0, 10]) override it: never model-authoredgate.policydisplay text. There is no per-product policy table. - Scanner Boundary: Scanners (gitleaks, zizmor) are installed and run inside the agent execution sandbox per the release security audit preset (
backend/presets/006-release-security-audit.yaml), never on the platform control plane.
Tamper-evident audit trail and signed records¶
- Chain: A background pass seals audit rows per account into a hash chain (
chain_seq,prev_hash,row_hash). Editing, deleting, or reordering a sealed row breaks every hash from that point. Sealing is off the request path so a chain error cannot fail the audited action. Signed checkpoints over the chain head are the artifact a customer can keep off the platform. - Keys: One active Ed25519 key per account. The private half is Fernet-encrypted with
SECURITY__ENCRYPTION_KEY; the public half is published. Rotation retires the old key without invalidating signatures it already made. - Signatures: Detached, over a digest, with the payload type inside the signed bytes so a period-export signature cannot be replayed as an evidence-pack signature. Evidence packs are signed at capture, not at download. Signing is never a precondition: an unsigned export or pack is still served.
- Verification:
preloop audit verifyandpreloop evidence verifyrecompute hashes and signatures on the caller's machine and tell the caller to trust that walk over the server's verdict. Operator guide: Evidence storage and signed records. - What this does not prove: A compromised server can forge a record before it is signed. The chain is not WORM. Rows below the retention purge floor and rows not yet sealed are outside any result.
Authentication & Authorization¶
Preloop implements authentication and multi-tenancy:
Authentication:
- JWT-based authentication for REST API and MCP endpoints
- Token-based authentication with refresh token support
- Email verification for new user accounts
- Integration points for SSO and OAuth providers (future)
- Per-user auth_generation (JWT gen claim). POST /auth/sessions/revoke-all
increments it; get_current_user, POST /auth/refresh, the CLI JWT
branch of POST /oauth/token, and the WebSocket upgrade
(WebSocketAuthMiddleware) reject a token whose gen is behind the
user. Tokens minted before the claim existed are treated as generation 0,
so one bump also invalidates them. API keys and runner tokens are
unchanged. preloop auth logout --all and the console Sign out everywhere
control call that endpoint. Canonical identity revocation bumps the person's
generation and every linked membership's generation together.
- POST /auth/logout is the console's server-side sign out. It runs the
registered H9 SessionHook.on_logout (from preloop.plugins.account_hooks)
with the current token's claims and returns redirect_url: a same-origin
path the console navigates to next, or null for the default.
run_logout_hook drops any redirect that is not a same-origin path, and
the console checks it again. With no hook registered it changes nothing
server-side; the console clears its own tokens either way. Sign out
everywhere skips this call because revoke-all already ended the session.
- SessionHook.is_token_revoked runs after the generation and CLI session
checks in reject_revoked_token, so an extension can revoke a single JWT it
issued. With no hook registered it is not called.
- Console refresh tokens (POST /auth/refresh) carry sat and are capped
at MAX_SESSION_DAYS (default 30). CLI login refresh tokens stay
long-lived (CLI_JWT_REFRESH_TOKEN_EXPIRE_DAYS, default 365); revocation
is the control for those, not the session cap.
- Each CLI login (/oauth/token without PKCE) records a cli_session row.
Its access and refresh JWTs carry the row id as sid; the refresh token
also carries a jti that must equal cli_session.refresh_jti. Rotation
swaps the jti in one conditional UPDATE, so a refresh token that was
already rotated away is rejected. Revoking the row (POST /oauth/revoke
with either token, DELETE /auth/sessions/cli/{id}, preloop auth logout)
rejects both tokens of that login in get_current_user, the WebSocket
upgrade and the refresh path; other logins are unaffected.
POST /auth/refresh does not accept a token with sid, so a CLI refresh
token cannot be turned into console tokens that escape the row check.
A CLI refresh token from before sid existed is moved onto a new row the
next time it rotates; the old token itself stays covered by the
generation check only.
- POST /oauth/revoke still revokes opaque MCP tokens. A JWT without sid
(console tokens, CLI tokens from before cli_session) gets 400
unsupported_token_type pointing at POST /auth/sessions/revoke-all
instead of a false success.
Session revocation and live connections¶
The database is authoritative. Identity changes (including password reset,
address changes and verification, suspension and self-service sign-out) bump
the canonical person's generation and its membership generations. Membership
offboarding affects only that account. SSO enforcement increments the matching
memberships' non_sso_auth_generation; the installed authentication policy
decides which sessions remain valid.
POST /api/v1/users/{user_id}/sessions/revoke-all uses the existing
manage_users permission and an account-scoped lookup. It bumps only that
membership's generation, without invoking cross-membership revoke fan-out,
revokes its CLI session rows and writes auth.sessions.revoked in one
transaction. It leaves the membership active so the person can sign in again.
Roles are enforced by the commercial RBAC plugin; core retains its existing
full-access-per-member model within an account.
| Credential or connection | Self-service sign-out everywhere | Admin member sign-out |
|---|---|---|
| Console and CLI access/refresh JWTs | All linked memberships | Selected account membership |
| CLI session records | Revoked across linked memberships | Revoked for selected membership |
| Live JWT WebSockets | Closed with code 4401 when revoked |
Same, scoped to selected membership |
| API keys, agent-control credentials and runner tokens | Unchanged | Unchanged |
| Anonymous WebSockets | Unchanged | Unchanged |
The live JWT registry covers unified and legacy console sockets, execution and flow-lifecycle streams, runtime-session attach, and desktop viewers. It is separate from the account broadcast registry so tracking a lifecycle stream never subscribes it to unrelated account events. API-key-authenticated viewers, desktop host connections, managed-agent control sockets and runner sockets keep their separate credential lifecycle. Offboarding has its own, broader credential teardown; sign-out does not perform offboarding.
Generation-writing CRUD stages only membership IDs and generation numbers on
the transaction. The application's services/auth_revocation.py commit hook
publishes auth.revoked through the bounded realtime publisher after the root
transaction commits. Rolled-back transactions/savepoints publish nothing.
This also covers plugin-owned transactions that call core CRUD. Every API
replica subscribes without a NATS queue group. A notification re-runs
reject_revoked_token against fresh database state for the named JWT sockets,
so delayed notifications cannot close a newer login or an exempt SSO session.
Each JWT socket also rechecks immediately on registration and every 60 seconds, independent of client traffic. A missed event, disconnected NATS publisher or full publisher queue therefore falls back to the next database check. A transient database failure is logged and retried on the next interval; no within-one-minute guarantee is possible while the database is unavailable. Handlers remove their watcher tasks on disconnect. Individual CLI-session and extension revocations are also detected by this periodic check.
There is no browser-session inventory or single-browser-session revocation table. See account and profile controls for the console actions and credential boundaries.
Restricted CI identity foundation¶
CiPrincipal is an OSS machine principal with a versioned, typed grant for one
account-local project and its explicitly bound hosted flow. Separate ApiKey
rows have an explicit restricted_ci mode, version, principal reference and
optional narrower action ceiling. Tokens use the existing high-entropy hashed
key lifecycle and are disclosed once. Authentication reads current account,
principal, key and binding state, intersects action ceilings, and returns a
machine context with no human permissions. Scope enforcement audit/off settings
never relax this mode. The binding snapshots provider identity, clone path,
tracker host/type and organization lineage; changing any of these invalidates
access rather than redirecting an existing grant.
API keys with scopes keep them across REST and MCP. Tool discovery and reads
need mcp:read and tool invocation needs mcp:write, on top of the existing
account, runtime and tool authorization. An MCP-only key (flow execution
credentials, runtime session tokens, managed-agent credentials) is refused on
generic REST routes outside the runtime routes that verify their own
credentials, and single-purpose keys (report_discovery,
telemetry:ingest) reach only their own routes
(backend/preloop/api/auth/key_scopes.py). Personal API keys without scopes
keep their previous behaviour.
Human administrators provision a restricted CI identity through eleven
authenticated /api/v1/ci-identities routes: capabilities, preview, list,
create, show, update, retire, key issue, key rotate, key revoke, and
completion-subscription create. The console Restricted CI page and preloop ci
use that same lifecycle. Generic REST, MCP, model gateway, WebSocket and
session exchange authentication cannot treat a restricted key as its issuing
human. The legacy key management surface excludes these keys; human CI
administration must use the dedicated lifecycle seam.
The restricted ASGI guard covers every application role before handler work.
Only one canonical Bearer transport can enter an explicitly classified,
machine-aware HTTP handler. Duplicate or custom token transports, WebSockets,
streams, unclassified operations and generic credential/session exchanges deny.
An explicit test inventory includes lazy included routers, hidden routes and
mounted applications through their middleware wrappers. OpenAPI records each
operation's x-restricted-ci policy and authentication/authorization errors.
Machine-aware handlers use get_current_actor and an explicit ci_action on
require_permission; unmarked handlers reject machine actors, including direct
service calls. The machine branch never invokes the human RBAC/owner path.
CRUD revalidates key, principal, account, action and immutable binding before
protected work, so a retained request context cannot preserve revoked authority.
The optional machine account-policy hook can only deny this core ceiling;
missing hooks preserve OSS behavior and hook errors fail closed. Invalid
restricted credentials receive 401, forbidden operations receive 403, and
protected object handlers apply their consistent not-found policy. Decision
logs include safe machine/resource identifiers, never token or result contents.
The operation map permits the existing flow trigger, execution list,
execution detail, persisted result and stop-command routes, plus principal-owned
subscription list/create/update/delete and signing-secret rotation. All other
operations remain denied. Trigger accepts only {"pr_number": 7, "head_sha": "<exact SHA>"};
the SHA is 40 or 64 lowercase hexadecimal characters. OpenAPI includes the
restricted request schema beside the ordinary human request contract. The server
reads the PR/MR by immutable repository ID using its trusted integration and
requires an open review, matching immutable repository identity, number and exact
head. v1 supports hosted GitHub and GitLab repositories; fork reviews and custom
GitHub hosts are refused. Effective GitLab hosts must match the approved binding.
No execution row or queue operation is created for an invalid review.
The accepted account, principal, project, flow, integration, repository, provider
PR identity, head and target branch are persisted with the execution. Attribution
and that snapshot cannot be edited or backfilled onto historical rows. The target
branch must be a conventional ASCII Git ref; its accepted value is included in
clone context, so a non-main target does not fall back to main. Dispatch checks
the current principal/grant/resource binding before queueing and re-reads the
provider review immediately before a new runtime launch. Provider failures block
launch with a generic verification_blocked failure, without recording provider
response bodies or credentials. Revoking only the initiating key does not cancel
accepted work; principal or trigger-grant removal blocks future launch. Recovery
of an already established runtime remains available to human administrators.
Machine list/detail/result/stop queries require the current key/grant and stable principal ownership, before filtering, counting or pagination. Other principals, human runs and null-owned history are inaccessible. Detail returns only review correlation, status, timestamps and failure category. Result returns the same correlation plus the persisted report with controller-private fields, prompts, MCP logs and runtime credential/configuration fields removed recursively. A missing result returns 404 even for a completed run. An agent-authored report is not proof that a review was published to the provider; publication receipts must be checked independently.
Stop accepts only {"command": "stop"} (an explicit null payload is also
accepted), with no other fields or command payload. It uses the existing runtime
teardown path, rechecks authority after connecting to the command bus, and is
idempotent for owned terminal runs. CI decides which PR/head is obsolete; this
endpoint does not enforce a supersession policy. v1 is one hosted execution:
matrix, retry, resume, delegation, flow-feedback loops and triage controllers are
excluded. Model, agent, runner, credentials, prompts and clone/workspace settings
remain administrator-controlled flow configuration.
Key rotation preserves principal-owned runs. Principal disablement or grant removal denies subsequent API reads/stops without automatically terminating an already running agent. An authorized human administrator uses ordinary execution controls to inspect or stop that run. Restoring a grant requires the same approved resource binding; moving a project/repository requires a new principal rather than silently redirecting existing executions.
The account owner can administer through core CRUD. An optional edition hook checks human operation/resource authority and may delegate or deny human administration, while the core account, project, flow and action checks always apply. Grant changes, disablement and key lifecycle changes are audited with human, principal, key and resource/action identifiers, never token material. Disabling an unusable binding or revoking its key remains possible for an authorized administrator. Permission changes to the provisioning human do not implicitly change the machine's persisted grant.
Execution and subscription ownership is nullable for historical rows and is
never guessed or backfilled. Rotation atomically replaces a key, preserving
principal ownership and narrowing to the previous key/current grant intersection.
Revoking one key does not stop existing work or transfer/delete owned resources.
Disable a principal to suspend all its machine access; stopping existing runs is
a separate administrator action. Principals and bound resources use restrictive
foreign keys to preserve attribution. Disabling is reversible and keeps the
flow binding. Retiring (POST /api/v1/ci-identities/{id}/retire) is permanent:
it disables the principal, revokes all of its keys and clears
ci_principal.flow_id, so the bound flow can be deleted, while the grant still
records the original flow and owned rows keep their attribution. A retired
principal cannot be re-enabled, reissued, subscribed or granted a result export;
provision a new identity instead. Explicitly remove other retained dependent
records before resource deletion. Deleting a human retains the principal's nullable
administration attribution; existing key/user deletion semantics still revoke
that human's keys. Schema rollback refuses restricted keys or retained principals
rather than erase machine markers and turn a CI key into an owner key.
Multi-User Architecture:
- Account Model: Represents an organization/company
- User Model: Represents individual users within an account
- All data is scoped by account_id for multi-tenancy isolation
Security Features:
- Password hashing with industry-standard algorithms
- Account-level data isolation (all queries filtered by account_id)
- User invitation system with secure token-based email verification
- Email verification and password reset links are bound to one user row (a
uid claim next to the address). One address can hold a user in several
accounts, so a link never resolves by address alone: it is refused if it has
no uid (links minted before this binding) or if the row's address changed
after it was sent, and the person requests a new one. The forgot-password
and resend-verification forms send one link per row holding the address,
each naming its username. crud_user.get_by_email raises
AmbiguousEmailError rather than choose between rows; use list_by_email
or an account scope.
Plugin System:
- Extensible plugin architecture for adding custom functionality
- Plugins can provide services, API routes, middleware, and dependencies
- Built-in plugins: Argument-based condition evaluator for approval workflows
- Plugin discovery via module paths or file system paths
- Lifecycle hooks: on_startup() and on_shutdown()
Enterprise Features: Preloop (OSS) includes users, invitations and teams, isolates accounts from each other, and applies no access control between users of one account. Preloop Cloud and Preloop Enterprise add roles, owner protection and RBAC/ABAC. Audit collection, the query API, and the console are included in open source; Enterprise applies its role permissions to that same account-scoped trail. Contact sales@preloop.ai for more information.
- [x] All API requests authenticated via JWT tokens
- [x] Multi-tenant data isolation (all queries scoped by account_id)
- [x] User invitation system with secure token-based verification
- [x] Password hashing with industry-standard algorithms
- [x] Input validation for all parameters via Pydantic models
- [x] Issue tracker credentials encrypted at rest via the Secret Service (
credentials_secret_id/webhook_secret_id→SecretReference; a startup backfill migrates legacy tracker API-key rows; staged schema migrations remove legacy webhook and AI-model key columns) - [x] Sensitive data masked in logs (see Redaction Policy below)
- [x] Rate limiting on auth, OAuth, public approval, passkey challenge, verification resend and forgot-password, OTLP ingest and admin test-push endpoints, shared across replicas (operations/rate-limiting.md)
- [x] Passkeys that verify the user (device plus PIN or biometric), and per-user passkey-only sign-in (see Passkey sign-in below)
- [ ] TOTP codes, recovery codes and account-wide MFA enforcement
- [x] Session revocation via per-user token generation (
auth_generation) - [x] Per-login CLI session revocation (
cli_session, JWTsid/jti) - [ ] Regular security audits and dependency updates
Enterprise Security: Preloop Cloud and Preloop Enterprise add fine-grained RBAC/ABAC and organizational controls. Audit logging, the audit hash chain, retention, legal holds and signed exports are available in every edition. Contact sales@preloop.ai for more information.
Redaction Policy¶
Preloop redacts sensitive data before logging, persisting to audit surfaces, or sending notifications. The centralized redaction module (preloop.utils.redaction) provides:
redact_dict(data): Recursively replaces values for sensitive field names (e.g.password,api_key,token,secret,credential) with***REDACTED***.redact_for_log(data): Produces a safe JSON string for log messages, with sensitive fields redacted and output truncated.
Redaction is applied in: - MCP tool execution logs (tool arguments) - Approval flow logs and notifications (tool args, approval URLs) - Flow execution MCP usage logs (persisted to DB) - Audit trail (configuration changes, tool executions) - Approval request emails and Slack/Mattermost messages
Known exceptions: Approval URLs are not logged in full (replaced with [sent via notification]). Progress tokens and request context metadata are not logged. Tracker credentials and AI model API keys are not logged when present in payloads.
Tests: tests/utils/test_redaction.py asserts that representative secrets never appear in redacted output.
Restricted CI completion callbacks¶
Restricted CI credentials manage only subscriptions owned by their stable
principal through the existing endpoint list/create/update/delete routes and
POST /api/v1/event-webhooks/endpoints/{endpoint_id}/secret/rotate. Each accepts
its specific subscription action. Create defaults to exactly
flow.execution.finished; explicit empty, mixed or duplicate filters, foreign
resource fields and ownership/configuration overrides are rejected. The binding
snapshots account, principal, project, flow and repository immutably; historical
account-wide endpoints are never inferred to be machine-owned.
Dispatch requires the stable principal's current subscription:create grant,
active account/principal and unchanged resource binding, plus any EE denial.
Removing that action or disabling the principal prevents future delivery,
including queued retries. Revoking an initiating API key blocks that key's API
use while retaining principal-owned subscriptions and accepted delivery rights.
Ownership is checked against the trusted persisted terminal execution when
queuing and again after a worker acquires a send slot, immediately before POST.
Partial or malformed machine markers are quarantined, with no human-envelope
fallback. Generic test-send, replay, delivery-history and dead-letter routes
remain denied to machine credentials; generic administrator replay excludes
machine subscriptions.
Human operators see a read-only restricted-CI marker and the fixed completion filter. Synthetic test sends are explicitly rejected for these endpoints; ordinary pause, receiver and deletion controls remain available. Permanent authorization or private-target denials terminate a queued delivery without POST. Transient DNS resolution and preparation/database failures consume a failed attempt, release the claim and use normal backoff, circuit breaking and dead-letter limits. Preparation logs contain delivery IDs and exception classes, never exception contents or signing material.
The signed v1 envelope contains only execution/flow/project/repository, PR/MR number/provider identity, exact head SHA, terminal status and result readiness. Caller event data, runtime prompts, logs and report bodies never enter the machine payload. Consumers verify HMAC freshness, deduplicate event IDs across retries, match execution and expected head, then read that execution's persisted result. A signed completion is not a review-publication receipt and a stale head is not current review evidence.
Signing secrets are encrypted and returned once on creation or rotation. Queued and retried sends use the current receiver URL and current secret with a fresh signature timestamp. A request already in flight can still use the old secret: receivers should accept both during their bounded rotation overlap and deduplicate by event ID. Endpoint deletion removes its durable delivery history; authority-denied queued machine deliveries are marked dead without POST. The migration does not backfill and refuses downgrade while retained snapshots exist.
Account kill switch¶
The account kill switch serializes halt transitions and runtime admission on the account row. Audit records and durable execution stop intent share the transition transaction. Monitors and recovery workers distinguish a stop request from confirmed runtime termination; approval deadlines recover once by their actual frozen interval.
Passkey sign-in¶
Passkey registration and sign-in request userVerification: "required" and
verify the user-verification (UV) flag in the authenticator data. A passkey
sign-in therefore proves two factors: the device holding the key and the PIN
or biometric that unlocked it. An attestation or assertion without the UV flag
is refused with 400 passkey_user_verification_required. Stored credentials
need no migration; the flag is checked on every ceremony.
PASSKEYS_REQUIRE_USER_VERIFICATION=false (default true) is the rollback
switch: it restores preferred for security keys without a PIN, and while it
is off passkey-only sign-in cannot be newly enabled (409
user_verification_disabled). Persons with a passkey-only membership keep
the UV requirement while the switch is off. PASSKEYS_ENABLED=false locks
passkey-only users out entirely; reset them first. Changing the setting
refuses CLI login tokens (sid), so a reissued pair cannot escape
per-login revocation.
Passkey-only sign-in. A user can require a passkey for their own
membership (user.passkey_only, GET/PUT /api/v1/auth/webauthn/passkey-only).
Changing it in either direction needs a session whose amr contains
passkey and whose auth_time is at most 10 minutes old (403
passkey_sign_in_required), so a password thief cannot switch it off and a
session from before the UV requirement cannot switch it on. Enabling needs at
least one passkey (409 no_passkey_registered), bumps the membership's
non-SSO generation (non_sso_auth_generation) so every other non-SSO session
ends, live WebSocket connections included, and returns a fresh token pair for
the current session. The last passkey of a person with a passkey-only
membership cannot be deleted (409 last_passkey_required); enable and delete
lock the person and user rows in the same order.
Enforcement lives in preloop.services.authentication_policy
(sign_in_method_allowed), which every sign-in path reaches through
new_authentication or authorize_authentication:
| Path | Passkey-only behaviour |
|---|---|
/auth/token, /auth/token/json, MCP OAuth consent form |
Correct password: 401 password_login_disabled, no tokens. Wrong password: the usual generic error, so the setting is not revealed |
Email link (/auth/verify-email), chat exchange, social sign-in |
401 password_login_disabled, no tokens |
| Account switch from a weaker session | Refused the same way |
| Password reset | Changes the password, never signs in, never clears the setting |
| Passkey, SSO (OIDC, SAML) | Unchanged |
| API keys, runner tokens | Unchanged; the CLI signs in through the browser |
Core also refuses non-SSO sessions whose nsg claim is behind the
membership's non-SSO generation, with or without a policy plugin.
Lost device: an operator runs `python scripts/reset_passkey_only.py --email
--reason