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.
- Loopback-only binding. The callback server binds
127.0.0.1on an ephemeral port — never0.0.0.0, never a fixed port. Nothing off the machine can reach it, and nothing can squat the port in advance. - The
stateparameter. The CLI generates a 32-character randomstate, puts it in the URL it opens, and requires the callback body to echo it back. A callback with a missing or mismatchedstateis rejected withLOGIN_STATE_MISMATCHand the session is discarded. This is what stops another local process (or a stray browser tab, or a page the user was tricked into opening) from POSTing an attacker-chosen session into a CLI that happens to be waiting. - Bounded lifetime. The server accepts exactly one callback and shuts down;
if none arrives within 300 seconds the command fails with
LOGIN_TIMEOUTrather than leaving a listener open. The permissive CORS headers on/callback(Access-Control-Allow-Origin: *) exist only so the site's fetch succeeds — they widen who may send to the endpoint, which is precisely whystateand not origin is the thing being trusted. - Tokens are never printed. Neither the access token nor the refresh token
is written to stdout, in
--jsonmode or out of it.crosscode login --jsonemits only{"value":{"userId":"…","email":"…"}}. This keeps credentials out of terminal scrollback, CI logs, and any agent transcript that captures command output. For the same reason there is noCROSSCODE_TOKENenvironment variable to set or leak. - The session lands in a mode-
0600file. Both login paths persist through the same daemon config writer to<git-dir>/crosscode/config.json, owner read/write only, outside versioned files — with the refresh token preferring the OS keychain where one exists (see below). The browser path introduces no new storage location and no new credential type. - Headless is not a downgrade path.
--email/--passwordsigns in against Supabase directly with no loopback server and nostateinvolved, so agents and CI never exercise the browser surface at all.
Revocation
Three credentials can be taken away, and none of them requires waiting for an expiry:
- A member.
crosscode members remove <memberId>(DELETE /v1/members/:id, owner only) setsmembers.disabled_at. Every authorization path —resolveMembership,resolveWorkspaceToken,assertReplicaOwnership— already filters on it, so access ends on the next request. The row is disabled rather than deleted because operations, validations, and audit events reference it and history has to stay attributable. The same transaction retires their replicas and revokes their workspace tokens, so removing someone does not leave their machines still ingesting. A workspace always keeps at least one owner, and an owner cannot remove themselves. - A paired device.
crosscode devices revoke <tokenId>(DELETE /v1/workspace-tokens/:id, owner only) setsworkspace_tokens.revoked_atand disables the associated replica.ccw_tokens never expire and are not self-describing — they are opaque random bytes resolved against the database on every single request — which is exactly what makes immediate revocation possible. - A Supabase session.
crosscode logoutclears this checkout's session (and anyccw_token) from the config file and the OS keychain, and signs out of Supabase.
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:
- Applying a high- or critical-risk operation.
- Requesting a semantic review when not pre-approved by workspace policy
(
configuredAiReviewPolicy'sexternalAiReview: "approved"plusrequireLocalConfirmation). - Publishing a Git commit (
publishrequires a prior passing validation and, outsidedryRun, an explicit confirmation or--yes). Publishing writes a commit and moves a local branch ref; it never pushes to a remote, and nothing in Crosscode does. - Changing remotes, branch policy, or workspace membership. Removing a member
(
crosscode members remove) and revoking a paired device (crosscode devices revoke) both prompt unless--yesis passed, and both are owner-only.
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:
- Website ↔ CLI: the only thing the site ever hands the CLI is a Supabase
session, over the loopback callback described above, guarded by
state. The site holds no workspace state, issues no Crosscode-specific credential, and cannot reach a daemon. There is no browser surface that reads or writes coordination data at all, so a compromised web page's blast radius stops at "can attempt to deliver a session to a login that is already waiting" — which is whatstateis there to reject. - Daemon ↔ local filesystem: fully trusted. The daemon reads and writes the checkout it manages directly; there is no sandboxing between the daemon process and the repository it watches.
- Daemon ↔ coordination service: authenticated with a Supabase-issued JWT
plus the
x-crosscode-workspace-idheader naming the target workspace. Every request re-derives role and membership server-side (resolveMembership), not just the token payload. - Service ↔ PostgreSQL: the runtime connects with a least-privilege role
(
CROSSCODE_RUNTIME_DB_ROLE) that cannot update/delete immutableoperationsoraudit_eventsrows, and the service refuses to start with a role that can. The runtime never executes DDL. Row Level Security policies (004_supabase_auth.sql) are defense-in-depth on top of this — the service itself still connects with a privileged role rather than through PostgREST, so application-level authorization inresolveMembershipremains the primary enforcement point; the RLS policies matter if the Supabase project's PostgREST/anon/authenticated roles are ever used to query these tables directly.
What a malicious or compromised replica can do, given its role's server-side
checks (apps/service/src/auth.ts, store.ts):
- Upload operations, tasks, claims, handoffs, and intents within its own role's
permissions (an
owner/membercan write; aviewercannot — every ingest endpoint rejectsviewerwith 403). - See other members' presence, tasks, claims, handoffs, and intents within the same workspace, since these fan out to every subscribed replica in that workspace.
What it cannot do:
- Impersonate another member or replica: every ingest endpoint checks that the
event's
workspaceId/replicaId/actorIdmatch the authenticated identity and rejects a mismatch with 403. - Bypass per-workspace authorization: all reads and writes are scoped to the
caller's
workspaceId; there is no cross-workspace query path. - Force materialization on another replica: remote operations only ever arrive as proposals. A receiving daemon decides locally whether to accept, and the service has no mechanism to push a write into another checkout.
View raw markdown · generated from
docs/security.md at build time, do not hand-edit this page.