Security model

Authentication

Workspace members authenticate directly against Supabase Auth — crosscode login (loopback browser callback) or crosscode login --email/--password (headless); the daemon stores the resulting Supabase session (short-lived access token plus a longer-lived refresh token) rather than a Crosscode-issued credential. The coordination service verifies each request's access token against Supabase's own signing key (verifySupabaseAccessToken, apps/service/src/auth.ts) — Supabase signs access tokens with an asymmetric key (ES256 by default), fetched and cached from <SUPABASE_URL>/auth/v1/.well-known/jwks.json, not a shared secret, so there is nothing equivalent to CROSSCODE_JWT_SECRET to configure or leak. SUPABASE_URL is still used to check the expected token issuer (<SUPABASE_URL>/auth/v1) and the authenticated audience. Claims (SupabaseAccessClaims):

{
  userId: string;             // JWT subject; Supabase auth.users id
  email: string | undefined;
  expiresAt: string;
}

A Supabase access token carries only the member's auth.users id — not a workspace, replica, or role scope the way Crosscode's own previously-issued tokens did. Every authenticated request must therefore also carry an x-crosscode-workspace-id header naming the workspace it targets (apps/service/src/http.ts); the service then re-derives role and membership server-side for that (userId, workspaceId) pair on every request (resolveMembership), so a disabled member loses access immediately rather than waiting for its token to expire. POST bodies also carry their own event.workspaceId, which is checked against the header for a redundant principal-binding match.

Sign-in threat model

The browser sign-in path exists so a human does not have to type a password into a terminal. It moves a live Supabase session from a web page into a local process, which is exactly the shape that OAuth loopback redirects have to get right, so the same defenses apply.

Revocation

Three credentials can be taken away, and none of them requires waiting for an expiry:

Both server-side revocations are refused to a ccw_ token (assertSupabaseCredential): team management stays behind a real Supabase session, so a leaked terminal-side credential cannot revoke its peers or remove the owner who would revoke it. Both are audited (member.removed, workspace_token.revoked).

Provisioning and replica self-registration

Workspace and member provisioning is still an administrator-side operation (pnpm service:provision), but it now creates or invites a Supabase Auth user by email through the Supabase admin API (SUPABASE_SERVICE_ROLE_KEY) and writes the corresponding workspace/member row straight to Postgres — there is no one-time enrollment token or replica secret anymore. A replica (an individual daemon/device identity) is self-registered by the authenticated member calling POST /v1/replicas (CoordinationServiceClient .ensureReplicaRegistered, apps/daemon/src/service-client.ts), which the daemon does automatically the first time it starts with a logged-in session, rather than being minted by exchanging an admin-issued token. The Supabase session's refresh token is stored in the OS keychain when available (macOS security, Linux secret-tool), the same way the replica secret used to be; otherwise it falls back to the daemon's local, mode-0600 config file (<git-dir>/crosscode/config.json) outside versioned files — never committed, never sent anywhere but Supabase and the coordination service. The daemon refreshes an expiring access token automatically (refreshAccessToken) using the stored refresh token, and re-persists the rotated session through the same keychain-preferred path.

Redaction

redactValidationOutput (apps/daemon/src/index.ts) truncates validation output to 64 KB and regex-replaces likely secrets in place:

/((?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*)([^\s]+)/gi

matches are replaced with $1[REDACTED].

Separately, configuredExcludedPaths/matchesConfiguredExclusion (apps/daemon/src/config.ts) read excludedPaths from the committed .crosscode/config.yaml at HEAD and glob-match (minimatch, dot: true) outgoing file paths against them. Excluded paths are dropped before a change is even captured as a transaction — they never reach the redaction step because they never leave the local checkout.

Sensitive-action confirmation

Per BUILD_INSTRUCTIONS.md section 16, these require explicit local user approval regardless of automation elsewhere:

The AI semantic reviewer (BUILD_INSTRUCTIONS.md section 12) is bounded and non-authoritative: it cannot write files or publish commits directly, must require human approval for high/critical risk regardless of its own confidence score, and must never receive secrets, .env contents, credentials, private keys, or excluded paths. Review is delegated to the workspace member's own already-connected MCP agent (Claude Code, Codex CLI, etc.) rather than a separate external AI provider: AgentDelegatedReviewer (packages/core/src/agent-delegated-reviewer.ts) parks the redacted review bundle behind GET /v1/semantic-reviews/pending until the connected agent calls the submit_semantic_review MCP tool (POST /v1/semantic-reviews/:requestId/submit, docs/mcp-clients.md) with its judgment, or the request times out into the safe uncertain/ requiresHumanApproval fallback. Crosscode stores, configures, or transmits no separate AI provider credentials for this — the redaction, prompt-injection resistance, risk safety gate, and audit-record guarantees described above and in BUILD_INSTRUCTIONS.md section 12 apply identically to the agent-delegated bundle.

Concretely, prompt-injection resistance on that path means each pending review carries a prompt alongside its structured request: SEMANTIC_REVIEW_SYSTEM_PREAMBLE plus the file content wrapped in explicit <untrusted-content> delimiters (buildSemanticReviewPrompt, packages/core/src/semantic-review.ts). The reviewing agent is itself an LLM reading repository text that may contain instructions aimed at it, so it receives that text already framed as data rather than as a bare JSON blob it has to decide how to interpret. The preamble states that the delimited content is never instructions, that the reviewer has no tool, file, Git, or publish capability, and that a human decides what happens next — which is true: resolveSemanticReview only writes an audit record.

policy.autoApplyRisk

An optional policy.autoApplyRisk field on the committed .crosscode/config.yaml (enum low | medium | high | critical, default low within an explicit policy block) lets the daemon auto-materialize newly-arrived proposals instead of waiting for an explicit accept. It does not add a new materialization path or weaken any gate above: a proposal is only auto-applied if it already passes the same assertApplicable/assertChangeApplicable checks a manual accept would require (today, only the independent/low-risk classification satisfies that), and its risk is at or under the configured threshold. Critical-risk paths are never eligible regardless of policy. An auto-applied proposal is recorded with a distinct transaction.auto_applied local event so it's visibly different from a human-initiated accept. No policy block committed (the default) leaves today's always-explicit-accept behavior completely unchanged.

Threat model

Trust boundaries:

What a malicious or compromised replica can do, given its role's server-side checks (apps/service/src/auth.ts, store.ts):

What it cannot do:

View raw markdown · generated from docs/security.md at build time, do not hand-edit this page.