diff --git a/HANDOFF.md b/HANDOFF.md
index 7bdf026..744a0ee 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -3,6 +3,64 @@
Open items each workstream wanted to change in files another workstream owns. Add a section
rather than replacing this file — earlier handoffs were lost that way.
+# 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.
+
# Workstream D (terms acceptance mechanism)
Changes I wanted to make in files another workstream owns. None of them are blocking; the
diff --git a/README.md b/README.md
index 2b09c3e..a990203 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 5f82101..3039ee7 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
- Untracked files
- - Secrets, even when tracked:
.env*, *.pem, *.key
+ - Secrets, even when tracked:
.env*, .ssh/, .aws/, *.pem, *.key, *credentials*, kubeconfig, *.tfvars (the exact list)
- Your commits, branches, index, and stash
- Your remotes. Nothing here pushes
@@ -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 217a43d..098a94b 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));
}