Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 7 additions & 7 deletions apps/docs-site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ <h2>A sync layer, not another Git wrapper</h2>
<li class="reveal"><b>Pure Git underneath.</b> One ref holds the last agreed state. <code>HEAD</code> never moves and nothing appears in <code>git log</code>.</li>
<li class="reveal"><b>One-command undo.</b> Anything Crosscode wrote comes back with <code>git checkout refs/crosscode/shadow -- &lt;path&gt;</code>.</li>
<li class="reveal"><b>Same-branch rooms.</b> Sync happens within one project and one branch name. Different branches means you did not want to sync.</li>
<li class="reveal"><b>Secrets never move.</b> <code>.env*</code>, <code>*.pem</code>, <code>*.key</code>, and similar are dropped before capture. Untracked files are never sent.</li>
<li class="reveal"><b>Secrets never move.</b> Environment files, <code>.ssh/</code>, <code>.aws/</code>, <code>.kube/</code>, private keys and keystores, <code>*credentials*</code>, <code>kubeconfig</code>, <code>*.tfvars</code> and <code>*.tfstate</code> are dropped before capture. Untracked files are never sent.</li>
<li class="reveal"><b>MCP native.</b> A standard stdio MCP server, so there is no editor extension to install.</li>
<li class="reveal"><b>Survives the network.</b> The daemon keeps capturing offline and catches up from its cursor when it reconnects.</li>
</ul>
Expand Down Expand Up @@ -236,9 +236,9 @@ <h2>Five rules it will not break</h2>
<h2>We can read the files you sync</h2>
<p class="lede">
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.
</p>
</div>

Expand All @@ -247,7 +247,7 @@ <h2>We can read the files you sync</h2>
<h3>Never leaves your machine</h3>
<ul>
<li>Untracked files</li>
<li>Secrets, even when tracked: <code>.env*</code>, <code>*.pem</code>, <code>*.key</code></li>
<li>Secrets, even when tracked: <code>.env*</code>, <code>.ssh/</code>, <code>.aws/</code>, <code>*.pem</code>, <code>*.key</code>, <code>*credentials*</code>, <code>kubeconfig</code>, <code>*.tfvars</code> (<a href="https://github.com/amsultan2010/crosscode/blob/main/packages/core/src/index.ts">the exact list</a>)</li>
<li>Your commits, branches, index, and stash</li>
<li>Your remotes. Nothing here pushes</li>
</ul>
Expand All @@ -262,7 +262,7 @@ <h3>What the service stores</h3>
<li>Timestamps, sizes, and sequence numbers</li>
<li>Presence: your branch and recently touched paths, in memory only</li>
</ul>
<p class="seal-note">Kept about 7 days, which is what makes offline catch-up work.</p>
<p class="seal-note">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.</p>
</div>
</div>

Expand Down Expand Up @@ -425,7 +425,7 @@ <h2>Asked before installing</h2>
<details class="faq-item reveal">
<summary>Can you read my code?</summary>
<div class="faq-body">
<p>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 <a href="/docs/privacy.html">privacy page</a> lists everything the service holds.</p>
<p>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 <a href="/docs/privacy.html">privacy page</a> lists everything the service holds.</p>
</div>
</details>
<details class="faq-item reveal">
Expand Down
22 changes: 17 additions & 5 deletions apps/docs-site/src/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@
// 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.

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();
Expand Down Expand Up @@ -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;
}
}
32 changes: 31 additions & 1 deletion apps/docs-site/src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand All @@ -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 = `<a href="/auth/signup.html">Create an account</a>`;
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");
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 16 additions & 8 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading
Loading