Skip to content

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 in x-api-key (Bearer fallback) and returns {score, threshold, primary_outcome}; primary_outcome is omitted for benign content. Auth reuses the model gateway's authenticate_bearer_token, so a standard Preloop API key is the routed credential.
  • Scoring: services/security_screen.py is 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 from PRELOOP_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-Policy and, on HTTPS requests, Strict-Transport-Security, all with always so error responses carry them too. X-XSS-Protection is not sent: current browsers ignore it.
  • One definition per config: nginx drops server-level add_header directives in any location or if block that declares its own add_header, which used to strip the set from the SPA shell, static assets, /admin and /api/. The set now lives in frontend/docker/security-headers.conf (Docker image, copied to /etc/nginx/snippets/) and the preloop.nginxSecurityHeaders named template in helm/preloop/templates/_helpers.tpl. Both configs include it at server level and in every block that sets headers. backend/tests/test_nginx_security_headers.py enforces 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 map turns X-Forwarded-Proto/$scheme into $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 as data: URLs. frame-src allows the privacy-enhanced video embeds on the landing page. media-src has no remote hosts, so blog hero_video files must be served from the console's own origin (see frontend/BLOG.md). script-src never contains 'unsafe-inline' or a scheme source.
  • Modes: CSP_MODE (Docker and Compose) or securityHeaders.csp.mode (Helm).
    • report-only (default in this release): the legacy enforcing Content-Security-Policy stays, and the new policy is sent as Content-Security-Policy-Report-Only. Browsers log violations of the new policy in the developer console and block nothing.
    • enforce: only the new policy, as Content-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 fails helm 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/accept and 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.extraScriptSrc and CSP_EXTRA_CONNECT_SRC / securityHeaders.csp.extraConnectSrc take space-separated host sources (https://cdn.example.com, *.example.com, 127.0.0.1:4000) appended to script-src and connect-src. Keywords, quotes, bare schemes such as https:, 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 to enforce.
  • Analytics: With analytics.enabled, the Helm chart injects <script src="/analytics-queue.js"> (a static file in the console build that queues window.plausible events) and the tracker tag, and adds the origin of analytics.scriptUrl to script-src and connect-src. A relative scriptUrl (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.customSnippet is raw HTML the chart cannot inspect, so no origin is added for it. Operators using it must list its origins in securityHeaders.csp.extraScriptSrc (and extraConnectSrc for where it sends events). Inline code in the snippet is blocked under enforce; 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 or javascript: 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-nginx backend/tests/test_nginx_headers_smoke.py. npm run test:csp in frontend/ 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 any securitypolicyviolation event or CSP console error.
  • Next step: A later release flips the default to enforce once the sweep has been green on main for a release.

Secret Service

  • Purpose: Provider-agnostic custody and resolution of model credentials.
  • Built-in Backend: local_encrypted for 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/rotate replaces 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 increments SecretReference.version and sets rotated_at, shown on models as credentials_version and credentials_rotated_at, and writes a secret_reference/rotated audit 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.py validates the result.json produced by release security audit runs. Previous SHA+path finding rows are a floor: dropping one without a resolved marker plus a reason fails, unclassified rows fail, and secrets_findings_count must match the row count. The agent never self-grades the floor.
  • Git Guard: git_guard.py allow-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 wires validate_gap_register into result ingestion.
  • Waiver Inputs: waivers.py deterministically 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. Interactive 006 waivers bind stored ask_user tool_result/responses (exact finding id and human reason), not status=approved or question text. Ambiguous request_approval prose, including a waive_finding operation, 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/CI gate.fail_on_kev / gate.fail_on_cvss_gte (in [0, 10]) override it: never model-authored gate.policy display 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 verify and preloop evidence verify recompute 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, JWT sid/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 ` in the API container. It clears the setting, ends the user's non-SSO sessions and writes `auth.passkey_only.reset`. There is no self-service recovery by email, which would reduce the account to one factor. Audit events `auth.passkey_only.enabled`, `.disabled`, `.login_refused` and `.reset` carry the user id and bounded fields only, never credential material. ## Audit collection and authentication audit Audit collection, the account-scoped query API, and the console are core features available in every edition. Core owns the audit service, bounded writer and optional durable outbox; Enterprise uses the same implementation and adds its RBAC permission checks and events from organizational features. Audit rows are sealed into a per-account hash chain with signed checkpoints. Period exports and evidence packs carry detached Ed25519 signatures. `preloop audit verify` and `preloop evidence verify` recompute both on the caller's machine. See [Evidence storage and signed records](../guide/flows/evidence-storage.md). The chain proves order and non-deletion in the range it names, not that the records were true when written; the signing key lives beside the records. Password and passkey sign-in record one final `auth.login.succeeded` or `auth.login.failed` outcome through `preloop.services.auth_audit`. Identity plugins use the same helper for social sign-in and SSO/SCIM lifecycle events. Payloads retain bounded identity metadata, known changed-field names and UUID-only owner lists; tokens, assertions and nested provider payloads are excluded. Known identities route to their account audit trail and period exports; attempts without a trustworthy account emit a content-free service security log, never a caller-selected tenant row. Existing `user.login` activity events remain available for cohort metrics. SSO labels retain their schema bounds (name 255, domain 253, group/issuer/entity 512 characters); secret-looking identifier labels remain intentional identifiers. Identity URLs drop userinfo, query strings and fragments. Owner lists retain at most 100 UUIDs and report the original total when truncated; nested values never survive. Authentication audit volume is intentionally one outcome per attempt, without sampling or deduplication. Password login is throttled per client address and per username (failed attempts only) by the shared request limiter ([docs/operations/rate-limiting.md](../operations/rate-limiting.md)), which keys on the first address outside the trusted proxy ranges, never on a client-written forwarded header. Passkey verification has no built-in attempt limiter yet, so operators should enforce an ingress limit on `/api/v1/auth/webauthn/authenticate/verify`. A known-account failure adds one audit row; sustained attempts therefore grow the account's retained audit history and period exports linearly. The ordinary audit retention policy applies, and the period export row cap requires smaller periods for high-volume accounts. Unknown-account failures grow service logs; apply the same ingress limits and log rotation there. Limit attempts before authentication, rather than suppressing required compliance events afterward. ## Restricted external runtime authority The default-disabled restricted runtime primitive reuses SecretReference policy metadata and hashed ApiKey/runtime-session rows. A trusted provider adapter supplies verified account/binding/session/creator identity. Core issuance locks the account-owned reference, checks its static resource/scope ceiling, current agent, validated enrollment and active policy snapshot, and atomically records a single secret delivery with permanent session replay state. Revocation locks the same reference and records a tombstone before deactivating the existing key. Policy updates preserve that state and increment the generation. Machine markers prevent fallback to the key owner's ordinary authority. Only the DynamicFastMCP transport opts into restricted authentication. Invocation resolves an immutable server UUID and original upstream tool; the fresh dispatch gate rechecks current core state before policy/approval and after approval and connection waits. Unsupported routes, builtin tools and asynchronous approval replay deny. Legacy authentication and upstream grant introspection keep their existing behavior. Real concurrency regressions use independent transactions and private schemas in the isolated CI PostgreSQL database. Devin CLI host execution uses bounded ATIF exports for completion and stores the CLI-created session id before subsequent remote turns. Model inference stays on Cognition; managed MCP and permission hooks use Preloop. Optional deployments may register `analytics_detail_policy` to reduce derived gateway analytics after a monthly quota is exhausted. The post-response path keeps token/cost accounting, audit and session activity, marks affected rows with `meta_data.ingest_degraded`, and omits content search, content-derived summaries and tool enrichment. LLM session optimization falls back to its deterministic analyzers. With no registered policy, OSS retains full detail. The policy never changes forwarded requests or governance decisions.