From 798ee8fe33e2a8689e87e0a0d191fc6dd011c6ec Mon Sep 17 00:00:00 2001 From: Abdullah Sultan Date: Thu, 6 Aug 2026 12:32:02 -0500 Subject: [PATCH] Make the privacy and secrets claims true - Analytics: drop the persistent localStorage id for a tab-scoped sessionStorage visit id, so nothing that outlives the tab is stored on a visitor's device (ePrivacy Art. 5(3) covers localStorage, not just cookies). - Denylist: widen redactPath to cover credential filenames, service-account JSON, kubeconfig, Terraform vars and state, .aws/.ssh/.kube/.gnupg and more key extensions, with false-positive tests for source files that only look credential-shaped. - Copy: replace "anything credential-shaped" with the enumerated list, name Supabase and Vercel as the source of encryption at rest, and stop claiming history is deleted after 7 days when no retention sweep exists. - Delete the dead service:billing-sweep script. Co-Authored-By: Claude Opus 5 (1M context) --- HANDOFF.md | 57 ++++++++++++++++++++++++ README.md | 8 +++- apps/docs-site/index.html | 14 +++--- apps/docs-site/src/analytics.js | 22 +++++++--- apps/docs-site/src/analytics.test.ts | 32 +++++++++++++- docs/architecture.md | 6 ++- docs/observability.md | 5 ++- docs/security.md | 24 ++++++---- package.json | 1 - packages/core/src/index.test.ts | 66 ++++++++++++++++++++++++++++ packages/core/src/index.ts | 33 +++++++++++++- 11 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..3af2df6 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,57 @@ +# Handoff from workstream E (truthfulness) + +Changes I would have made in files another workstream owns. I did not touch them. + +## 1. Retention: the 7-day claim is not delivered by any code — please reword (A and B) + +`HISTORY_RETENTION_DAYS = 7` (`apps/service/src/store.ts:68`) is only used to answer a +replica whose cursor has aged out with `cursor-too-old`. **There is no retention sweep +anywhere in the repo.** `package.json` points `service:prune` at +`apps/service/src/prune.ts`, which does not exist; `PLAN.md:167` ticks `prune.ts` and +`retention.ts` as done and they do not exist either; `apps/service/migrations/001_sync.sql` +gives `file_versions` no DELETE policy and no scheduled job. So change history is currently +retained **indefinitely**, and only the last ~7 days is replayable. + +"We keep it for 7 days" is a deletion promise to a data subject. Suggested wording: + +- `docs/privacy.md:41` ("About 7 days.") → + "About 7 days of history is replayable, which is what makes offline catch-up possible. + Pre-1.0 caveat: the sweep that deletes older history is not running yet, so the service + still holds it. Until it is, treat the retention period as indefinite." +- `docs/terms.md:110` ("Change history is retained for about seven days regardless.") → + "About seven days of change history is replayable regardless. Older history is not served + back, and the sweep that deletes it is not running yet." +- `docs/support.md:65` ("History older than about 7 days is gone.") → + "History older than about 7 days is no longer replayed to a checkout." + +I already made the equivalent change in the files I own: `apps/docs-site/index.html` +(privacy seal note), `docs/architecture.md:85`, `docs/security.md:112`. + +**The real fix is to build the sweep**, then revert all of this wording. Whoever owns +`apps/service`: a `prune.ts` that deletes `file_versions` older than +`HISTORY_RETENTION_DAYS`, run on a schedule, closes the gap. + +## 2. Encryption at rest (A and B) + +`docs/privacy.md:6` and `docs/terms.md:89` say files are encrypted at rest "under keys we +manage". Crosscode adds no encryption of its own; the at-rest encryption is whatever +Supabase and Vercel provide, under keys **they** hold. Suggest "encrypted at rest by our +hosts, Supabase and Vercel, under keys they hold". I made that change in +`apps/docs-site/index.html` and `docs/security.md:93`. + +The owner should keep the vendor pages evidencing this (Supabase and Vercel encryption-at- +rest documentation) in the compliance folder, so the claim has a source if it is ever +questioned. + +## 3. Denylist wording (B, if `docs/privacy.md` enumerates it) + +The denylist is now an enumerated list of patterns in `packages/core/src/index.ts`. Copy +the category list from `docs/security.md` ("What is never sent") if privacy.md repeats it. + +## 4. Other dead scripts in `package.json` (owner) + +I deleted `service:billing-sweep` as briefed. Two more scripts point at files that do not +exist and will fail the moment anyone runs them: `service:prune` +(`apps/service/src/prune.ts`) and `service:provision` +(`apps/service/src/provision-admin.ts`). Left alone — my brief allowed one deletion — but +they should go or be implemented. `PLAN.md:167` should stop claiming they are done. diff --git a/README.md b/README.md index 5d6213f..78172eb 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,12 @@ Codex also get a pre-edit hook. ### Secrets never move -`.env*`, `*.pem`, `*.key`, and anything credential-shaped are dropped before capture, even -when Git tracks them. Untracked files are never sent. +Dropped before capture, even when Git tracks them: `.env*` and `.envrc`/`.npmrc`/`.netrc`/ +`.pgpass`/`.htpasswd`; `.aws/`, `.ssh/`, `.kube/`, `.gnupg/`; `id_rsa` and friends; +`*.pem`/`*.key`/`*.p12`/`*.pfx`/`*.jks`/`*.gpg`/`*.asc`; `*credentials*`, +`*service-account*.json`, `kubeconfig`; `*.tfvars` and `*.tfstate`. The full list is one +regex in [`packages/core/src/index.ts`](./packages/core/src/index.ts). Untracked files are +never sent. [Docs →](./docs/privacy.md) diff --git a/apps/docs-site/index.html b/apps/docs-site/index.html index e906507..8a21f23 100644 --- a/apps/docs-site/index.html +++ b/apps/docs-site/index.html @@ -197,7 +197,7 @@

A sync layer, not another Git wrapper

  • Pure Git underneath. One ref holds the last agreed state. HEAD never moves and nothing appears in git log.
  • One-command undo. Anything Crosscode wrote comes back with git checkout refs/crosscode/shadow -- <path>.
  • Same-branch rooms. Sync happens within one project and one branch name. Different branches means you did not want to sync.
  • -
  • Secrets never move. .env*, *.pem, *.key, and similar are dropped before capture. Untracked files are never sent.
  • +
  • Secrets never move. Environment files, .ssh/, .aws/, .kube/, private keys and keystores, *credentials*, kubeconfig, *.tfvars and *.tfstate are dropped before capture. Untracked files are never sent.
  • MCP native. A standard stdio MCP server, so there is no editor extension to install.
  • Survives the network. The daemon keeps capturing offline and catches up from its cursor when it reconnects.
  • @@ -236,9 +236,9 @@

    Five rules it will not break

    We can read the files you sync

    Your edits pass through our coordination service. There is no end-to-end - encryption: files are encrypted in transit and at rest under keys we manage, - which means someone with production access could read them. We would rather say - that plainly than imply otherwise. + encryption: files travel over TLS and sit at rest inside Supabase and Vercel, + encrypted under keys those providers hold, which means someone with production + access could read them. We would rather say that plainly than imply otherwise.

    @@ -247,7 +247,7 @@

    We can read the files you sync

    Never leaves your machine

    @@ -262,7 +262,7 @@

    What the service stores

  • Timestamps, sizes, and sequence numbers
  • Presence: your branch and recently touched paths, in memory only
  • -

    Kept about 7 days, which is what makes offline catch-up work.

    +

    About 7 days of change history is replayable, which is what makes offline catch-up work. Pre-1.0: the sweep that deletes older history is not running yet, so the service still holds it.

    @@ -425,7 +425,7 @@

    Asked before installing

    Can you read my code?
    -

    The files you sync, yes. There is no end-to-end encryption, so contents are encrypted in transit and at rest under keys we manage and a Crosscode engineer with production access could read them. If that is not acceptable for a repository, do not sync it: Crosscode is opt-in per checkout. The privacy page lists everything the service holds.

    +

    The files you sync, yes. There is no end-to-end encryption: contents travel over TLS and are encrypted at rest by our hosts, Supabase and Vercel, under keys they hold, so a Crosscode engineer with production access could read them. If that is not acceptable for a repository, do not sync it: Crosscode is opt-in per checkout. The privacy page lists everything the service holds.

    diff --git a/apps/docs-site/src/analytics.js b/apps/docs-site/src/analytics.js index ba60225..e396e22 100644 --- a/apps/docs-site/src/analytics.js +++ b/apps/docs-site/src/analytics.js @@ -11,6 +11,11 @@ // With no VITE_POSTHOG_KEY this file installs no listeners, sets no storage and sends no // request. That is the state a fork or a local `pnpm docs:dev` runs in. // +// Nothing persistent is written to the visitor's device: no cookie, and no storage that +// outlives the tab. The id that groups one visit's events lives in `sessionStorage`, so it +// dies with the tab and cannot link today's visit to next week's. Counting a funnel does +// not need to know who you are twice. +// // It posts to PostHog's capture endpoint directly rather than loading posthog-js. Four // events do not need 60 kB of autocapture and session recording on a page whose pitch is // that we cannot read your code, and every field that leaves the browser is listed below. @@ -18,7 +23,7 @@ const KEY = import.meta.env.VITE_POSTHOG_KEY; const HOST = (import.meta.env.VITE_POSTHOG_HOST ?? "https://us.i.posthog.com").replace(/\/$/, ""); -const DISTINCT_ID_STORAGE_KEY = "crosscode_distinct_id"; +const VISIT_ID_STORAGE_KEY = "crosscode_visit_id"; const ONCE_PREFIX = "crosscode_analytics_once:"; if (KEY) install(); @@ -103,15 +108,22 @@ export function capture(event, properties = {}) { .catch(() => undefined); } -/** A random id in localStorage. No email, no account id, nothing derived from the visitor. */ +/** + * A random id for this visit, held in sessionStorage so that the two pages of the funnel + * (landing, then sign-up) count as one visit. It is gone when the tab closes, so it cannot + * be used to recognise a returning visitor. No email, no account id, nothing derived from + * the visitor. If storage is denied, each page load gets its own in-memory id instead. + */ +let inMemoryVisitId; function distinctId() { try { - const existing = localStorage.getItem(DISTINCT_ID_STORAGE_KEY); + const existing = sessionStorage.getItem(VISIT_ID_STORAGE_KEY); if (existing) return existing; const created = crypto.randomUUID(); - localStorage.setItem(DISTINCT_ID_STORAGE_KEY, created); + sessionStorage.setItem(VISIT_ID_STORAGE_KEY, created); return created; } catch { - return crypto.randomUUID(); + inMemoryVisitId ??= crypto.randomUUID(); + return inMemoryVisitId; } } diff --git a/apps/docs-site/src/analytics.test.ts b/apps/docs-site/src/analytics.test.ts index 24ede31..1c0ae0b 100644 --- a/apps/docs-site/src/analytics.test.ts +++ b/apps/docs-site/src/analytics.test.ts @@ -43,7 +43,8 @@ describe("site analytics without a PostHog key", () => { analytics.capture("landing_page_view"); expect(fetchSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem("crosscode_distinct_id")).toBeNull(); + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); }); }); @@ -62,6 +63,35 @@ describe("site analytics with a PostHog key", () => { expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://us.i.posthog.com/i/v0/e/"); }); + it("writes nothing to localStorage, and its visit id dies with the tab", async () => { + stubFetch(); + const button = landingPageWithCopyButton(); + + await loadAnalytics("phc_test_key"); + button.click(); + + // Nothing at all, not just nothing under the old key: a persistent id is what needs + // consent under ePrivacy Art. 5(3), whatever it is called. + expect(localStorage.length).toBe(0); + expect(Object.keys(localStorage)).toEqual([]); + // The visit id exists, but only in tab-scoped storage. + expect(sessionStorage.getItem("crosscode_visit_id")).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("keeps one visit id across the pages of a visit so the funnel joins up", async () => { + const fetchSpy = stubFetch(); + landingPageWithCopyButton().click(); + + await loadAnalytics("phc_test_key"); + window.history.replaceState({}, "", "/auth/signup.html"); + document.body.innerHTML = `Create an account`; + document.querySelector("a")?.click(); + + const ids = fetchSpy.mock.calls.map(([, init]) => JSON.parse(String(init.body)).distinct_id); + expect(ids.length).toBeGreaterThan(1); + expect(new Set(ids).size).toBe(1); + }); + it("counts sign_up_started once even when a link click and a form submit both fire", async () => { const fetchSpy = stubFetch(); window.history.replaceState({}, "", "/auth/signup.html"); diff --git a/docs/architecture.md b/docs/architecture.md index 97f2d30..20047f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,8 +82,10 @@ migration; `device_codes` carries no policy at all, which denies it to every rol not bypass RLS. The service assigns sequence numbers and fans changes out. It does not merge, classify, or -inspect anything. Changes are retained about 7 days; a replica whose cursor is older than -that is told to resync from full content rather than handed a partial history. +inspect anything. About 7 days of changes are replayable (`HISTORY_RETENTION_DAYS` in +`apps/service/src/store.ts`); a replica whose cursor is older than that is told to resync +from full content rather than handed a partial history. The sweep that deletes aged rows is +not built yet, so today the service keeps them past that window — see PLAN.md. A room is one project plus one branch name. Different branches do not sync, because different branches mean you did not want to. diff --git a/docs/observability.md b/docs/observability.md index 89b61fc..0a281fd 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -126,8 +126,9 @@ built file is 0.05 kB and contains no PostHog URL. Four events exist and no more: `landing_page_view`, `install_prompt_copied`, `sign_up_started`, `sign_up_completed`. They answer whether anyone activates. Each carries -`$current_url`, `$referrer` and a random `distinct_id` from `localStorage`. No email, no -account id, nothing derived from the visitor. +`$current_url`, `$referrer` and a random `distinct_id` held in `sessionStorage`. It dies +with the browser tab, so a returning visitor is a new one; nothing persistent is written to +the device. No email, no account id, nothing derived from the visitor. `sign_up_started` and `sign_up_completed` are counted once per browser tab session, so the link click on the landing page and the form submit that follows it are one event, not two. diff --git a/docs/security.md b/docs/security.md index 6806c42..ac6facd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -73,9 +73,15 @@ it manages, with no sandbox between the two. Enforced before a change is captured, not filtered afterwards: - **Untracked files.** Only files Git tracks are eligible. -- **A hard secret denylist**: `.env*`, `*.pem`, `*.key`, and credential-shaped paths, even - when tracked. Real-time syncing an `.env` would be a serious incident, so this check is - the earliest one in the pipeline. +- **A hard secret denylist**, even when tracked: environment files (`.env*`, `.envrc`, + `.npmrc`, `.netrc`, `.pgpass`, `.htpasswd`); the `.aws/`, `.ssh/`, `.kube/` and `.gnupg/` + directories; SSH keys (`id_rsa` and friends); keys and keystores by extension (`.pem`, + `.key`, `.p8`, `.p12`, `.pfx`, `.jks`, `.keystore`, `.ovpn`, `.gpg`, `.asc`); + credentials by name (`credentials`, `secrets`, `*-credentials.*`, + `*service-account*.json`, `kubeconfig`); and Terraform `*.tfvars`/`*.tfstate`. The list + is one array of patterns in `packages/core/src/index.ts` and nowhere else. Real-time + syncing an `.env` would be a serious incident, so this check is the earliest one in the + pipeline. - **Anything outside the working tree.** Symlinks that leave the checkout are refused. ## Integrity on receive @@ -90,8 +96,9 @@ garbage-in filter and nothing more. ## Encryption -TLS in transit, encryption at rest under keys the service manages. **There is no end-to-end -encryption**, no device pairing, and no key exchange. The service can read the file contents +TLS in transit. At rest, the data sits in Supabase Postgres and on Vercel, encrypted by +those providers under keys they hold; Crosscode adds no encryption layer of its own. +**There is no end-to-end encryption**, no device pairing, and no key exchange. The service can read the file contents it stores; [privacy.md](./privacy.md) says so in the same words. We built E2E once and removed it. It bought a real property and cost a device-pairing @@ -102,9 +109,10 @@ fewer moving parts and a plain statement of what we hold. ## Service ↔ database Row Level Security is on from the first migration. The service connects with a -least-privilege role that cannot rewrite history; the retention sweep that deletes aged -changes runs on a separate connection, so no request-handling path can reach a connection -able to erase history. +least-privilege role that cannot rewrite history: `file_versions` has no UPDATE or DELETE +policy at all, so no request-handling path can erase or alter a change. The retention sweep +that is meant to delete aged changes is not built yet; when it is, it runs as the owner on a +separate connection. ## What a malicious member can do diff --git a/package.json b/package.json index 69485d4..bd2118d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "service": "tsx apps/service/src/main.ts", "service:migrate": "tsx apps/service/src/migrate.ts", "service:prune": "tsx apps/service/src/prune.ts", - "service:billing-sweep": "tsx apps/service/src/billing-sweep.ts", "service:provision": "tsx apps/service/src/provision-admin.ts", "docs:dev": "pnpm --filter @crosscode/docs-site dev", "docs:build": "pnpm --filter @crosscode/docs-site build", diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 2dcfcb3..54de986 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -8,6 +8,72 @@ describe("core safety rules", () => { expect(redactPath("src/index.ts")).toBe(false); }); + // The exact list the README and docs/security.md enumerate. If a category moves, the + // copy moves with it. + it.each([ + ".env", + ".env.local", + "apps/service/.env.production", + ".envrc", + ".npmrc", + ".netrc", + ".pgpass", + ".htpasswd", + ".pypirc", + ".git-credentials", + "home/.aws/credentials", + ".aws/config", + ".ssh/known_hosts", + ".gnupg/secring.gpg", + ".kube/config", + "id_ed25519", + "keys/id_rsa.pub", + "credentials", + "config/credentials.yml", + "secrets/db.txt", + "aws-credentials.json", + "db_credentials.yaml", + "gcp-service-account.json", + "service-account-key.json", + "terraform.tfvars", + "infra/prod.tfvars.json", + "infra/terraform.tfstate", + "kubeconfig", + "kubeconfig.yaml", + "certs/server.key", + "certs/bundle.p12", + "certs/store.jks", + "vpn/office.ovpn", + "keys/backup.gpg", + "keys/release.asc", + "auth/AuthKey_ABC123.p8" + ])("never sends %s", (path) => { + expect(redactPath(path)).toBe(true); + }); + + // A false positive is silent: the file just stops syncing. These are ordinary source + // files that only look credential-shaped, and they must keep moving. + it.each([ + "src/keyboard.ts", + "docs/api-keys-guide.md", + "lib/secretsanta.js", + "packages/core/src/index.ts", + "src/monkeyboard/keys.tsx", + "docs/environment.md", + "app/services/account.ts", + "src/credentialsProvider.ts" + ])("still syncs %s", (path) => { + expect(redactPath(path)).toBe(false); + }); + + // Judgement call: a file whose own name is `credentials` stays denied even when it looks + // like a test. Test fixtures are where checked-in secrets most often hide, and the cost + // of being wrong is asymmetric -- an unsynced test file is an annoyance, a synced + // credential is an incident. + it("keeps denying credentials-named test files", () => { + expect(redactPath("test/credentials.test.ts")).toBe(true); + }); + it("hashes content stably and distinguishes different content", () => { expect(contentHash("a")).toBe(contentHash("a")); expect(contentHash("a")).not.toBe(contentHash("b")); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eeb6011..539bec2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,37 @@ import { createHash } from "node:crypto"; +/** + * The single definition of what never leaves the machine, grouped so that a reader can + * check the README and the docs against it line by line. Both ends check it: on the way + * out before capture, and on the way in through @crosscode/sync. + * + * A false positive is silent -- the file simply stops syncing and nobody is told why -- + * so every pattern is anchored to a path segment or to a file extension rather than + * matching anywhere in the path. `src/keyboard.ts`, `docs/api-keys-guide.md` and + * `lib/secretsanta.js` are ordinary source files and must keep syncing. + */ +const SECRET_PATH_PATTERNS = [ + // Environment files, and the tool configs that habitually hold tokens. + /(^|\/)\.env($|\.)/i, + /(^|\/)\.(envrc|npmrc|netrc|pgpass|htpasswd|pypirc|dockercfg|git-credentials)$/i, + // Directories whose whole purpose is credentials. + /(^|\/)\.(aws|ssh|gnupg|kube)(\/|$)/i, + // Private keys by name. + /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i, + // A path segment that is itself a credential store: credentials, secrets.yml, secrets/. + /(^|\/)(credentials?|secrets?)($|[./])/i, + // ...and the same word carried inside a filename: aws-credentials.json, db_credential.txt. + /[-_]credentials?($|[-_./])/i, + /service[-_]account[^/]*\.(json|ya?ml|pem|p12)$/i, + /(^|\/)kubeconfig([-_.][^/]*)?$/i, + // Keys, keystores and encrypted or armoured key material by extension. + /\.(pem|key|p8|p12|pfx|jks|jceks|keystore|keytab|ppk|kdbx|ovpn|asc|gpg|pgp)$/i, + // Terraform: variables and state both routinely carry plaintext secrets. + /\.tfvars(\.json)?$/i, + /\.tfstate(\.backup)?$/i +]; + export function contentHash(content: string | Buffer): string { return createHash("sha256").update(content).digest("hex"); } export function redactPath(path: string): boolean { - return /(^|\/)(\.env($|\.)|\.envrc$|\.npmrc$|\.netrc$|credentials($|[./])|secrets?($|[./])|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$)|\.(pem|key|p12|pfx|jks|keystore)$/i.test(path); + return SECRET_PATH_PATTERNS.some((pattern) => pattern.test(path)); }