codeceipt 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +51 -2
  2. package/child.mjs +298 -0
  3. package/codeceipt.mjs +25073 -0
  4. package/package.json +29 -4
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # codeceipt
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A receipt for every AI change. Codeceipt remembers your app across every Claude Code and Cursor session and checks
4
+ each change against what you asked for.
5
+
6
+ ## Set up
7
+
8
+ In your app's folder (a git repository), run:
9
+
10
+ ```bash
11
+ npx codeceipt
12
+ ```
13
+
14
+ 1. Your browser opens a page with the same short code as your terminal. Sign in with GitHub.
15
+ 2. Pick a project, or create one, and approve.
16
+ 3. Codeceipt links this repo to the project, indexes your code on your machine and sets up the agents it finds
17
+ (Claude Code, Cursor, OpenAI Codex, OpenCode, Google Antigravity), then prints a short summary.
18
+
19
+ Your agents run the `codeceipt` command, so install it once if the summary asks you to:
20
+
21
+ ```bash
22
+ npm install -g codeceipt
23
+ ```
24
+
25
+ No token or environment variable to set: the agents get the project's token from Codeceipt's own config on your
26
+ computer, and only for the server that issued it. Each repository has its own project.
27
+
28
+ ## What leaves your machine
29
+
30
+ Your source code never does. Codeceipt uploads file paths, hashes, component and function names, and short
31
+ summaries; secrets found in your code are masked (first 4 characters + `***`). The project link is stored in
32
+ `.git/codeceipt/project.json` (never committed); the token stays in your user config folder.
33
+
34
+ ## Commands
35
+
36
+ | Command | What it does |
37
+ | --- | --- |
38
+ | `codeceipt` | Connects this repo (browser approval), indexes it and sets up the agents |
39
+ | `codeceipt index [dir]` | Re-indexes the repo and uploads metadata only |
40
+ | `codeceipt init [--claude] [--cursor] [--codex] [--opencode] [--antigravity]` | Sets up the agents found here again |
41
+ | `codeceipt receipt` | Saves a receipt and progress now (OpenCode and Antigravity have no session-end event) |
42
+ | `codeceipt scan-secrets [files...]` | Scans changed (or named) files for secrets; prints masked findings |
43
+ | `codeceipt login [--api-url <url>]` | Saves a token you copied from the dashboard (read from stdin) |
44
+ | `codeceipt help` | Shows all commands |
45
+
46
+ `--api-url` (or `CODECEIPT_API_URL`) points the CLI at another Codeceipt server, for example a local one.
47
+
48
+ ## Help
49
+
50
+ Troubleshooting: https://codeceipts.vercel.app/docs/quickstart. Privacy: https://codeceipts.vercel.app/privacy.
51
+
52
+ Requires Node.js 20 or newer and git.
package/child.mjs ADDED
@@ -0,0 +1,298 @@
1
+ // The sandbox child process (D-014): runs untrusted migration SQL in an in-memory PGlite and reports what Postgres
2
+ // ended up with. It is started by runner.ts with an empty env and memory caps, and is killed from outside on timeout.
3
+ // Plain JavaScript so it runs on Node without a loader. It only answers one job over IPC, then exits.
4
+ import { PGlite } from "@electric-sql/pglite";
5
+ import { pgcrypto } from "@electric-sql/pglite/contrib/pgcrypto";
6
+ import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
7
+
8
+ // Supabase stand-ins, created before any user SQL (docs/specs/FEAT-005.md section 2). uuid-ossp and pgcrypto are
9
+ // pre-installed in schema extensions and that schema is on the search_path, as in Supabase.
10
+ const STAND_INS = `
11
+ create role anon nologin noinherit;
12
+ create role authenticated nologin noinherit;
13
+ create role service_role nologin noinherit bypassrls;
14
+ create schema auth;
15
+ create table auth.users (
16
+ id uuid primary key, email text, phone text, role text, aud text,
17
+ raw_app_meta_data jsonb, raw_user_meta_data jsonb,
18
+ created_at timestamptz default now(), updated_at timestamptz default now()
19
+ );
20
+ create function auth.uid() returns uuid language sql stable as $$ select null::uuid $$;
21
+ create function auth.jwt() returns jsonb language sql stable as $$ select '{}'::jsonb $$;
22
+ create schema storage;
23
+ create table storage.buckets (
24
+ id text primary key, name text not null, owner uuid, public boolean default false,
25
+ created_at timestamptz default now(), updated_at timestamptz default now()
26
+ );
27
+ create table storage.objects (
28
+ id uuid primary key default gen_random_uuid(), bucket_id text references storage.buckets (id), name text,
29
+ owner uuid, owner_id text, metadata jsonb, path_tokens text[], version text,
30
+ created_at timestamptz default now(), updated_at timestamptz default now(), last_accessed_at timestamptz default now()
31
+ );
32
+ alter table storage.buckets enable row level security;
33
+ alter table storage.objects enable row level security;
34
+ create schema extensions;
35
+ create schema net;
36
+ create schema vault;
37
+ create schema cron;
38
+ create extension "uuid-ossp" with schema extensions;
39
+ create extension pgcrypto with schema extensions;
40
+ `;
41
+ const SEARCH_PATH = `set search_path to "$user", public, extensions`;
42
+
43
+ // Migrations run as a superuser here, but as the non-superuser "postgres" in Supabase. A superuser could forge the
44
+ // result with catalog DML (update pg_class set relrowsecurity = true) that Supabase refuses. These triggers, on every
45
+ // pg_catalog table but UNGUARDED, refuse it the same way (42501). DDL writes the catalogs directly and is not affected. Before
46
+ // refusing, the guard sends a warning (its own client_min_messages, so user SQL cannot mute it) that the child sees
47
+ // even when user SQL swallows the error: any attempt makes the result unknown. pg_class and pg_authid are "nailed"
48
+ // catalogs whose triggers only load in a new session, so the database is dumped and reopened after this.
49
+ const GUARD_NOTICE = "brain_guard: catalog write refused";
50
+ // Triggers on these overflow the stack of PGlite's relcache (measured on 0.5.10); CATALOG_CHANGED watches their
51
+ // built-in rows instead, and GUARD_STATE the guard's own pg_trigger rows.
52
+ const UNGUARDED = ["pg_am", "pg_amproc", "pg_index", "pg_opclass", "pg_trigger"];
53
+ const GUARDED_FILTER = `relnamespace operator(pg_catalog.=) 'pg_catalog'::pg_catalog.regnamespace
54
+ and relkind operator(pg_catalog.=) 'r'::pg_catalog."char"
55
+ and relname operator(pg_catalog.<>) all ('{${UNGUARDED.join(",")}}'::pg_catalog.name[])`;
56
+ const GUARD = `
57
+ create schema brain_guard;
58
+ create function brain_guard.deny() returns trigger language plpgsql set client_min_messages = warning as $$
59
+ begin
60
+ raise warning '${GUARD_NOTICE}';
61
+ raise exception 'permission denied for table %', tg_table_name using errcode = '42501';
62
+ end $$;
63
+ set allow_system_table_mods = on;
64
+ do $$ declare t text; begin
65
+ foreach t in array array(select relname::text from pg_class where ${GUARDED_FILTER}) loop
66
+ execute format('create trigger brain_guard before insert or update or delete on pg_catalog.%I
67
+ for each row execute function brain_guard.deny()', t);
68
+ execute format('alter table pg_catalog.%I enable always trigger brain_guard', t);
69
+ end loop;
70
+ end $$;
71
+ reset allow_system_table_mods;
72
+ `;
73
+ // Row versions of the guard objects: any change by user SQL (disable, drop, replace, update) shows up here. The
74
+ // stand-in roles are pinned by oid and name, so renaming anon to service_role cannot disguise a policy's roles.
75
+ const GUARD_STATE = `select t.tgrelid::pg_catalog.text as name, t.xmin::pg_catalog.text as xmin,
76
+ t.tgenabled::pg_catalog.text as enabled
77
+ from pg_catalog.pg_trigger t where t.tgname operator(pg_catalog.=) 'brain_guard'::pg_catalog.name
78
+ union all
79
+ select p.oid::pg_catalog.text, p.xmin::pg_catalog.text, p.prosrc from pg_catalog.pg_proc p
80
+ where p.pronamespace operator(pg_catalog.=) 'brain_guard'::pg_catalog.regnamespace
81
+ union all
82
+ select r.oid::pg_catalog.text, 'role', r.rolname::pg_catalog.text from pg_catalog.pg_authid r
83
+ where r.rolname operator(pg_catalog.=) any ('{anon,authenticated,service_role}'::pg_catalog.name[])
84
+ order by 1`;
85
+ const GUARDED_COUNT = `select pg_catalog.count(*)::pg_catalog.int4 as n from pg_catalog.pg_class
86
+ where ${GUARDED_FILTER}`;
87
+ // A superuser may also replace what our final read relies on with DDL, which the guard triggers do not see
88
+ // (create or replace function pg_catalog.pg_get_expr ..., or view pg_catalog.pg_roles). Any pg_catalog function,
89
+ // relation, rule, type, operator or cast written after the baseline transaction counts as tampering, and so does any
90
+ // built-in row (oid below 16384) of the UNGUARDED catalogs.
91
+ const CATALOG_CHANGED = `select exists (select from pg_proc where pronamespace = 'pg_catalog'::regnamespace and $1 < xmin::text::int8)
92
+ or exists (select from pg_class where relnamespace = 'pg_catalog'::regnamespace and $1 < xmin::text::int8)
93
+ or exists (select from pg_rewrite r join pg_class c on c.oid = r.ev_class
94
+ where c.relnamespace = 'pg_catalog'::regnamespace and $1 < r.xmin::text::int8)
95
+ or exists (select from pg_type where typnamespace = 'pg_catalog'::regnamespace and $1 < xmin::text::int8)
96
+ or exists (select from pg_operator where oprnamespace = 'pg_catalog'::regnamespace and $1 < xmin::text::int8)
97
+ or exists (select from pg_cast where $1 < xmin::text::int8
98
+ and (select typnamespace from pg_type where oid = castsource) = 'pg_catalog'::regnamespace
99
+ and (select typnamespace from pg_type where oid = casttarget) = 'pg_catalog'::regnamespace)
100
+ or exists (select from pg_am where oid < 16384 and $1 < xmin::text::int8)
101
+ or exists (select from pg_amproc where oid < 16384 and $1 < xmin::text::int8)
102
+ or exists (select from pg_opclass where oid < 16384 and $1 < xmin::text::int8)
103
+ or exists (select from pg_index where indexrelid < 16384 and $1 < xmin::text::int8)
104
+ as changed`;
105
+
106
+ const REL_FILTER = `c.relkind in ('r', 'p') and n.nspname not in ('pg_catalog', 'information_schema')
107
+ and n.nspname !~ '^pg_(toast|temp_)'`;
108
+
109
+ // Run between user statements, so every name is schema-qualified: user SQL may change search_path or define
110
+ // functions and operators that would otherwise shadow the catalog ones.
111
+ const COUNTERS = `select pg_catalog.current_setting('search_path') as sp,
112
+ greatest((select pg_catalog.max(oid) from pg_catalog.pg_class),
113
+ (select pg_catalog.max(oid) from pg_catalog.pg_policy))::pg_catalog.text as max_oid,
114
+ (select pg_catalog.count(*)::pg_catalog.int4 from pg_catalog.pg_class c
115
+ where c.relkind operator(pg_catalog.=) 'r'::pg_catalog."char"
116
+ or c.relkind operator(pg_catalog.=) 'p'::pg_catalog."char") as tables,
117
+ (select pg_catalog.count(*)::pg_catalog.int4 from pg_catalog.pg_policy) as policies`;
118
+
119
+ // The stored expression trees (not the deparsed text, which depends on search_path) of one policy.
120
+ const CAPTURE = `select pol.polqual::pg_catalog.text as qual, pol.polwithcheck::pg_catalog.text as chk
121
+ from pg_catalog.pg_policy pol
122
+ join pg_catalog.pg_class c on c.oid operator(pg_catalog.=) pol.polrelid
123
+ join pg_catalog.pg_namespace n on n.oid operator(pg_catalog.=) c.relnamespace
124
+ where n.nspname operator(pg_catalog.=) $1::pg_catalog.name
125
+ and c.relname operator(pg_catalog.=) $2::pg_catalog.name
126
+ and pol.polname operator(pg_catalog.=) $3::pg_catalog.name`;
127
+
128
+ const TABLES = `select c.oid::text as oid, n.nspname as schema, c.relname as name, c.relrowsecurity as rls
129
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
130
+ where ${REL_FILTER} order by c.oid limit $1`;
131
+
132
+ const COLUMNS = `select a.attrelid::text as oid, a.attname as name, format_type(a.atttypid, a.atttypmod) as type
133
+ from pg_attribute a where a.attrelid = any ($1::oid[]) and a.attnum > 0 and not a.attisdropped
134
+ order by a.attrelid, a.attnum`;
135
+
136
+ const POLICIES = `select pol.oid::text as oid, n.nspname as schema, c.relname as table, pol.polname as name, pol.polpermissive as permissive,
137
+ case pol.polcmd when 'r' then 'SELECT' when 'a' then 'INSERT' when 'w' then 'UPDATE' when 'd' then 'DELETE'
138
+ else 'ALL' end as command,
139
+ case when pol.polroles = '{0}' then array['public']::text[]
140
+ else array(select rolname::text from pg_roles where oid = any (pol.polroles) order by rolname) end as roles,
141
+ pg_get_expr(pol.polqual, pol.polrelid) as using, pg_get_expr(pol.polwithcheck, pol.polrelid) as with_check,
142
+ pol.polqual::text as qual, pol.polwithcheck::text as chk
143
+ from pg_policy pol join pg_class c on c.oid = pol.polrelid join pg_namespace n on n.oid = c.relnamespace
144
+ order by pol.oid limit $1`;
145
+
146
+ process.once("message", (job) => {
147
+ run(job).then(
148
+ (result) => reply({ type: "result", ...result }),
149
+ (err) => reply({ type: "fatal", message: String(err?.message ?? err) }),
150
+ );
151
+ });
152
+
153
+ function reply(message) {
154
+ process.send(message, () => process.exit(0));
155
+ }
156
+
157
+ async function run({ statements, cap }) {
158
+ const extensions = { uuid_ossp, pgcrypto };
159
+ const setup = await PGlite.create({ extensions });
160
+ await setup.exec(STAND_INS + GUARD);
161
+ const image = await setup.dumpDataDir("none");
162
+ await setup.close();
163
+ const db = await PGlite.create({ loadDataDir: image, extensions });
164
+ await db.exec(SEARCH_PATH);
165
+ const guarded = (await db.query(GUARDED_COUNT)).rows[0].n;
166
+ const guard = await guardState(db, guarded);
167
+ const baseline = (await db.query("select pg_catalog.txid_current()::pg_catalog.text as xid"))
168
+ .rows[0].xid;
169
+ const standIns = new Map((await db.query(TABLES, [cap + 1])).rows.map((t) => [t.oid, t.rls]));
170
+ const initial = await counters(db);
171
+ let last = initial;
172
+ let outcome = "ok";
173
+ const steps = [];
174
+ let catalogWrite = false;
175
+ const onNotice = (notice) => {
176
+ if (notice?.message === GUARD_NOTICE) catalogWrite = true;
177
+ };
178
+ for (const st of statements) {
179
+ const before = st.capture ? await capture(db, st.capture) : undefined;
180
+ let error;
181
+ try {
182
+ await db.exec(st.sql, { onNotice });
183
+ } catch (err) {
184
+ // The reader masks and cuts to 200 characters; cut here too so huge messages cannot exhaust the child.
185
+ error = { message: String(err?.message ?? err).slice(0, 1000), code: err?.code };
186
+ }
187
+ const after = st.capture && !error ? await capture(db, st.capture) : undefined;
188
+ last = (await counters(db)) ?? last;
189
+ steps.push({ error, before, after, maxOid: last?.max_oid ?? "0", searchPath: last?.sp ?? "" });
190
+ // Out of memory (SQLSTATE 53200): stop; the catalog is still read, and the reader makes every table unknown.
191
+ if (error?.code === "53200") {
192
+ outcome = "oom";
193
+ break;
194
+ }
195
+ if (last && (last.tables > initial.tables + cap || last.policies > initial.policies + cap)) {
196
+ outcome = "capped";
197
+ break;
198
+ }
199
+ }
200
+
201
+ // SAVEPOINT only works inside a transaction block: success means user SQL left one open, 25P02 an aborted one.
202
+ let openTransaction = false;
203
+ try {
204
+ await db.exec("savepoint brain_probe");
205
+ openTransaction = true;
206
+ } catch (err) {
207
+ if (err?.code === "25P02") openTransaction = true;
208
+ }
209
+ if (openTransaction) await db.exec("rollback");
210
+ // pg_temp is searched before pg_catalog for relations unless it is listed: a temp table named pg_class would
211
+ // otherwise answer the reads below.
212
+ await db.exec(
213
+ "reset all; reset session authorization; reset role; set search_path to pg_catalog, pg_temp",
214
+ );
215
+ const tampered =
216
+ guard === undefined ||
217
+ (await guardState(db, guarded)) !== guard ||
218
+ (await catalogChanged(db, baseline));
219
+
220
+ const tableRows = (await db.query(TABLES, [cap + standIns.size + 1])).rows.filter(
221
+ (t) => !standIns.has(t.oid) || standIns.get(t.oid) !== t.rls,
222
+ );
223
+ const policyRows = (await db.query(POLICIES, [cap + 1])).rows;
224
+ if (tableRows.length > cap || policyRows.length > cap) outcome = "capped";
225
+ const kept = tableRows.slice(0, cap);
226
+ const columns = new Map();
227
+ for (const c of (await db.query(COLUMNS, [kept.map((t) => t.oid)])).rows) {
228
+ const list = columns.get(c.oid) ?? [];
229
+ list.push({ name: c.name, type: c.type });
230
+ columns.set(c.oid, list);
231
+ }
232
+ const tables = kept.map((t) => ({
233
+ oid: t.oid,
234
+ schema: t.schema,
235
+ name: t.name,
236
+ rlsEnabled: t.rls,
237
+ columns: columns.get(t.oid) ?? [],
238
+ }));
239
+ const policies = policyRows.slice(0, cap).map((p) => ({
240
+ oid: p.oid,
241
+ schema: p.schema,
242
+ table: p.table,
243
+ name: p.name,
244
+ command: p.command,
245
+ roles: p.roles,
246
+ permissive: p.permissive,
247
+ using: p.using,
248
+ withCheck: p.with_check,
249
+ trees: { qual: p.qual, withCheck: p.chk },
250
+ }));
251
+ return {
252
+ outcome,
253
+ maxOid: initial.max_oid,
254
+ searchPath: initial.sp,
255
+ openTransaction,
256
+ tampered,
257
+ catalogWrite,
258
+ steps,
259
+ tables,
260
+ policies,
261
+ childEnvKeys: Object.keys(process.env),
262
+ };
263
+ }
264
+
265
+ async function catalogChanged(db, baseline) {
266
+ try {
267
+ return (await db.query(CATALOG_CHANGED, [baseline])).rows[0].changed === true;
268
+ } catch {
269
+ return true;
270
+ }
271
+ }
272
+
273
+ // One trigger per guarded catalog, the guard function and the three stand-in roles.
274
+ async function guardState(db, guarded) {
275
+ try {
276
+ const rows = (await db.query(GUARD_STATE)).rows;
277
+ return rows.length === guarded + 4 ? JSON.stringify(rows) : undefined;
278
+ } catch {
279
+ return undefined; // e.g. schema brain_guard dropped
280
+ }
281
+ }
282
+
283
+ async function counters(db) {
284
+ try {
285
+ return (await db.query(COUNTERS)).rows[0];
286
+ } catch {
287
+ return undefined; // e.g. inside an aborted transaction
288
+ }
289
+ }
290
+
291
+ async function capture(db, key) {
292
+ try {
293
+ const row = (await db.query(CAPTURE, [key.schema, key.table, key.name])).rows[0];
294
+ return row ? { qual: row.qual, withCheck: row.chk } : null;
295
+ } catch {
296
+ return undefined; // unknown: the reader then shows the catalog text
297
+ }
298
+ }