Skip to content
Open
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zomb",
"version": "0.0.1",
"version": "0.1.0",
"description": "A health check for AI-written codebases. Guard checks every edit before it lands and stops keys, @ts-ignore, skipped tests, V2 copies and duplicate libraries; /zomb-guard and /zomb-clean check and clean the rest.",
"author": {
"name": "Open Dev Society"
Expand Down
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.git
node_modules
dist
cloud/data
*.profraw
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Publishes zomb to npm when a version tag (v0.1.0) is pushed, with provenance.
# Trusted publishing needs no token: on npmjs.com, add Open-Dev-Society/zomb and release.yml as the
# package's trusted publisher, with npm publish allowed. Before the package exists there, the first
# publish can use an NPM_TOKEN repo secret instead; npm tries trusted publishing first either way.
name: release
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: The tag is the version in package.json
run: test "$GITHUB_REF_NAME" = "v$(node -p 'require("./package.json").version')"
- run: npm ci
- run: npm test
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
19 changes: 12 additions & 7 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Publishes site/ to GitHub Pages. Needs the repo public (or a paid plan), and Pages
# set to "GitHub Actions" in Settings once, so it is off until launch day.
# Publishes site/ to GitHub Pages, with The State of AI Code at /study/. Pages must be set to
# "GitHub Actions" in the repo's settings (once).
name: site
on:
workflow_dispatch:
push:
branches: [main]
paths: ['site/**', '.github/workflows/site.yml']
paths: ['site/**', 'research/data/state-of-ai-code.html', '.github/workflows/site.yml']
permissions:
contents: read
pages: write
Expand All @@ -20,9 +20,14 @@ jobs:
url: ${{ steps.deploy.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/upload-pages-artifact@v3
- uses: actions/checkout@v7
# the study page is rendered from research/data/summary.json by research/page.ts, and committed
- run: |
mkdir -p _site/study
cp -R site/. _site/
cp research/data/state-of-ai-code.html _site/study/index.html
- uses: actions/upload-pages-artifact@v5
with:
path: site
path: _site
- id: deploy
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ It prints a summary and writes a single HTML report to `.zomb/report.html` (git-

```bash
zomb --all # every finding in the terminal, not just the top 5
zomb show 3 # finding 3 from the list in full: where, why, how to fix
zomb --json # an ordered to-do list for an AI agent or CI
zomb --since main # only what your branch changed, plus the shortcuts it took
zomb --since HEAD # only your uncommitted work
zomb --traffic access.log # the routes no production request reached
```

## Compared with 336 public repos

Every whole-repo scan ends with how this repo compares against [The State of AI Code](research/): 52,688 commits in 336 public JS/TS repos since October 2025, measured the same way.
Every whole-repo scan ends with how this repo compares against [The State of AI Code](https://open-dev-society.github.io/zomb/study/): 52,688 commits in 336 public JS/TS repos since October 2025, measured the same way.

```
VS 336 PUBLIC REPOS ──────────────────────── agents wrote 100% of your new code
Expand Down Expand Up @@ -95,6 +97,17 @@ It refuses to run on uncommitted work, and it never touches scripts you may run

With `--since`, zomb also reads the diff for the ways agents make checks pass without fixing anything: new `@ts-ignore` and `eslint-disable`, `.skip` and `.only` tests, deleted tests and assertions, branches on `NODE_ENV === 'test'`, `as any`, and empty `catch` blocks.

## Dead routes, from real traffic

Without traffic, a page or API route that nothing in the repo links to is only a guess, so zomb asks about it. Give it your production requests and it knows:

```bash
zomb --traffic access.log # nginx or Apache logs
zomb --traffic drain.ndjson --traffic last-month.log.gz # several files, gzipped or not
```

It reads access logs, JSON lines or arrays (Vercel log drains, most log shippers, anything with a `path` or `url`), and plain `/path count` lines like an analytics export. Every route no request reached becomes a finding: "no links and no traffic" when nothing in the repo points to it either, "no traffic" when something still links to it. A dynamic route counts the requests it serves (`/blog/hello` keeps `/blog/[slug]` alive), and a locale prefix like `/fr/` is ignored. Only paths, counts and dates are read. Less than a week of traffic gets a warning, since a page used once a month would look dead. [zomb Cloud](cloud/README.md) collects the traffic for you from a log drain.

## Block new problems in CI

Existing debt shouldn't fail every build. Save it once as a baseline, commit it, and only new findings fail:
Expand Down Expand Up @@ -155,7 +168,7 @@ Any other agent can follow [skills/zomb-guard/SKILL.md](skills/zomb-guard/SKILL.
**Zombie code:** code that's in the codebase but not in use
- Files nothing imports or names, and files only their own tests keep alive
- Unused npm packages, and exports nothing imports
- Pages and API routes nothing in the repo links to (listed as "maybe", check your analytics)
- Pages and API routes nothing in the repo links to (listed as "maybe"), or, with `--traffic`, that no production request reached

**Sprawl:** the codebase only grows
- Lines added vs deleted each month
Expand Down
8 changes: 7 additions & 1 deletion cloud/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
# zomb Cloud: docker build -f cloud/Dockerfile -t zomb-cloud . && docker run -p 3000:3000 -v zomb:/data -e ZOMB_URL=https://your.host zomb-cloud
FROM node:26-slim
# the Docker CLI, for ZOMB_SANDBOX=docker: the server starts each scan in a throwaway container of this same image
COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* && npm install -g pnpm yarn
# where a sandboxed scan unpacks its checkout, as the image's unprivileged node user (uid 1000)
RUN mkdir /work && chown node:node /work
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src src
COPY cloud cloud
# a sandboxed scan runs /app/src/cli.ts as uid 1000, whatever modes the files had in the build context
RUN chmod -R a+rX src cloud
ENV DATA_DIR=/data PORT=3000
VOLUME /data
EXPOSE 3000
CMD ["node", "cloud/server.js"]
CMD ["node", "cloud/server.ts"]
42 changes: 36 additions & 6 deletions cloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ A GitHub App around the zomb CLI. Install it and you're done: no workflow file.

- **Every pull request** gets a `zomb` check and one comment, updated on each push. The check fails on new findings at or above `fail-on`.
- **Every push to the default branch** is scanned, and the dashboard shows each repo's trend: high security issues, zombie lines, unused packages, architecture and blueprint breaks.
- **Once a week** each repo gets one clean-up issue with zomb's verified task list, kept up to date and closed when the list is empty. It goes to the agent the team already pays for: `@claude` is mentioned in it, or one click in the dashboard assigns it to Copilot (Copilot only takes assignments from a person's token, not an app's). zomb never runs your builds: the agent does the work in your own CI.
- **Once a week** each repo gets one clean-up issue with zomb's verified task list, kept up to date and closed when the list is empty. It goes to the agent the team already pays for: `@claude` is mentioned in it, or it is assigned to Copilot, with one click in the dashboard or automatically every week. zomb never runs your builds: the agent does the work in your own CI.
- **Production traffic**, if you send it, tells each scan which pages and API routes nobody requests any more.

## Run it

```bash
ZOMB_URL=https://zomb.example.com node cloud/server.js
ZOMB_URL=https://zomb.example.com node cloud/server.ts
```

or with Docker:
Expand All @@ -32,13 +33,42 @@ Instead of `/setup`, you can pass an existing app's credentials: `GITHUB_APP_ID`
```yaml
fail-on: high # the PR check fails on new findings at this level (high, medium, low)
weekly: true # the weekly clean-up issue
handoff: claude # claude (mention @claude), copilot (a button in the dashboard) or none
handoff: claude # claude (mention @claude), copilot (a dashboard button, or automatic) or none
```

`.zomb/blueprint.yml` and `.zomb/baseline.json` work as they do in the CLI.

## Before you host it for other people
## Traffic: routes nobody visits

Scanning installs a repo's dependencies (with `--ignore-scripts`) and Knip loads its config files, so a scan runs some of that repo's code. Jobs get a clean environment with no server secrets, and the GitHub token never touches the checkout, but they still run as the server's user. That's fine for your own repos. Before scanning code you don't trust, run each job in its own container. The place to do that is `checkout()` in `cloud/jobs.js`.
Each repo's settings page (the **settings** link under its name on the dashboard) has a drain endpoint, `ZOMB_URL/drain/<repo id>`, and a token.

Not here yet: dead routes from production traffic, billing, and fully automatic Copilot hand-off (it needs a stored user token).
- **Vercel:** add a Drain for logs with JSON delivery to the endpoint, and use the token as its signature secret. zomb checks the `x-vercel-signature` header.
- **Anything else:** POST an access log, JSON lines or a JSON array with `Authorization: Bearer <token>`, gzipped (`Content-Encoding: gzip`) or not, up to 5 MB at a time.

zomb keeps only each path and a daily count, for 90 days. Static files are skipped, and a repo keeps at most 10,000 distinct paths a day. Once a repo has 14 days of traffic, push and weekly scans pass the last 30 days to `zomb --traffic`. The weekly issue then lists the routes no request reached. A route that is linked but never visited is asked about, and one with no links and no traffic is marked for deletion. **Make a new token** on the settings page turns the old one off.

## Copilot, automatically

Copilot only takes an assignment from a person, never from an app. On a repo's settings page, **Hand issues to Copilot every week** signs you in once more and keeps that GitHub token, so the weekly job can assign the issue as you. It does that only while the repo's `.zomb/config.yml` says `handoff: copilot`, and skips issues Copilot already has.

The token is encrypted at rest with AES-256-GCM. The key comes from `ZOMB_SECRET_KEY` (32 bytes, as hex or base64), or from a key generated into `DATA_DIR/secret.key`. The generated key sits next to the database, so it protects copies of the database (backups, dumps), not the disk. Set `ZOMB_SECRET_KEY` to keep the two apart. GitHub user tokens last 8 hours, so zomb refreshes them with the refresh token, which GitHub replaces on every use. If GitHub refuses the token, or you revoke zomb in your GitHub settings, zomb deletes it, puts the manual line back on the issue, and the dashboard shows **Copilot stopped** until someone turns it on again. Turning it off on every repo deletes the token.

## Scans in containers

Scanning installs a repo's dependencies (with `--ignore-scripts`) and Knip loads its config files, so a scan runs some of that repo's code. By default that happens as the server's user, in a clean environment with no server secrets. That's fine for your own repos.

Before you scan code you don't trust, set `ZOMB_SANDBOX=docker`. The server still fetches each repo itself, so the GitHub token never enters a container. It streams the checkout into a throwaway container, where the install and the scan run as an unprivileged user with no capabilities and no way to gain privileges, limited to 2 GB of memory, 2 CPUs and 512 processes, for at most 10 minutes. The container is removed when the scan ends.

- `ZOMB_SANDBOX_IMAGE`: the image scans run in, `zomb-cloud` by default. The Dockerfile above builds it, Docker CLI included. The server checks at startup that the image exists.
- `ZOMB_SANDBOX_NETWORK`: the Docker network scan containers join. Installing needs the package registry, so by default they use Docker's bridge network, which reaches whatever the host reaches. Give them a network that only reaches your registry (or a mirror) to keep scans out of your private network. With `none`, installs fail and Knip reads each repo without its dependencies, so some findings get less precise.

To run the server itself in Docker with the sandbox on, give it the host's Docker socket:

```bash
docker run -p 3000:3000 -v zomb:/data -v /var/run/docker.sock:/var/run/docker.sock \
-e ZOMB_SANDBOX=docker -e ZOMB_URL=https://zomb.example.com zomb-cloud
```

Whoever holds that socket controls the host. Only the server container gets it; scan containers never do. If that trade is wrong for you, run the server straight on a host with Docker, or point it at a rootless Docker daemon (`DOCKER_HOST`).

Not here yet: billing.
19 changes: 13 additions & 6 deletions cloud/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,22 @@ export function github({ appId, privateKey, clientId, clientSecret, api = 'https
as: (installation: number | string): Api => async (method, url, body) => request(await installationToken(installation), method, url, body),
user: (token: string): Api => (method, url, body) => request(token, method, url, body),
loginUrl: (state: string, redirect: string) => `${web}/login/oauth/authorize?client_id=${encodeURIComponent(clientId || '')}&state=${state}&redirect_uri=${encodeURIComponent(redirect)}`,
async signIn(code: string) {
const res = await fetch(`${web}/login/oauth/access_token`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }) });
const data = (await res.json()) as { access_token?: string; error_description?: string };
if (!data.access_token) throw new Error(data.error_description || 'GitHub sign-in failed');
return data.access_token;
},
signIn: (code: string) => userToken({ code }),
// user tokens of a GitHub App expire after 8 hours; the refresh token (6 months) gets a new pair, once
refresh: (refreshToken: string) => userToken({ grant_type: 'refresh_token', refresh_token: refreshToken }),
};
async function userToken(grant: Record<string, string>): Promise<UserToken> {
const res = await fetch(`${web}/login/oauth/access_token`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, ...grant }) });
const data = (await res.json()) as { access_token?: string; expires_in?: number; refresh_token?: string; refresh_token_expires_in?: number; error?: string; error_description?: string };
if (!data.access_token) throw Object.assign(new Error(data.error_description || 'GitHub sign-in failed'), { code: data.error });
const at = (s?: number) => (s ? Date.now() + s * 1000 : null);
return { token: data.access_token, expires: at(data.expires_in), refresh: data.refresh_token || null, refreshExpires: at(data.refresh_token_expires_in) };
}
}

/** A user's token. `expires` and the refresh token are null when the app's user tokens never expire. */
export type UserToken = { token: string; expires: number | null; refresh: string | null; refreshExpires: number | null };

// Webhooks are signed with the app's secret: anything else is someone pretending to be GitHub.
export function verify(secret: string, body: Buffer | string, signature: string | string[] | undefined = '') {
const want = Buffer.from(`sha256=${createHmac('sha256', secret).update(body).digest('hex')}`);
Expand Down
37 changes: 37 additions & 0 deletions cloud/grants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// The GitHub tokens people hand zomb so it can assign the weekly issue to Copilot without a click:
// Copilot only takes assignments from a person, never from an app. Sealed at rest, refreshed one at a time.
import type { UserToken } from './github.ts';
import type { Vault } from './vault.ts';

const EARLY = 5 * 60_000;

export function grants({ db, vault, gh }: { db: any; vault: Vault; gh: { refresh: (t: string) => Promise<UserToken> } }) {
db.exec('CREATE TABLE IF NOT EXISTS grants (login TEXT PRIMARY KEY, token TEXT NOT NULL, expires INTEGER, refresh TEXT, refresh_expires INTEGER)');
const put = (login: string, t: UserToken) =>
db
.prepare('INSERT INTO grants (login, token, expires, refresh, refresh_expires) VALUES (?, ?, ?, ?, ?) ON CONFLICT(login) DO UPDATE SET token = excluded.token, expires = excluded.expires, refresh = excluded.refresh, refresh_expires = excluded.refresh_expires')
.run(login, vault.seal(t.token), t.expires, t.refresh && vault.seal(t.refresh), t.refreshExpires);
// a refresh token works once: two jobs refreshing the same grant at the same time would lose it
const inflight = new Map<string, Promise<string>>();

async function token(login: string): Promise<string> {
const g = db.prepare('SELECT * FROM grants WHERE login = ?').get(login) as any;
if (!g) throw Object.assign(new Error(`no grant for ${login}`), { code: 'no_grant' });
if (g.expires === null || g.expires - Date.now() > EARLY) return vault.open(g.token);
if (!g.refresh || (g.refresh_expires && g.refresh_expires < Date.now())) throw Object.assign(new Error(`${login}'s grant expired`), { code: 'bad_refresh_token' });
const fresh = await gh.refresh(vault.open(g.refresh));
put(login, fresh);
return fresh.token;
}

return {
save: put,
has: (login: string) => Boolean(login && db.prepare('SELECT 1 FROM grants WHERE login = ?').get(login)),
drop: (login: string) => db.prepare('DELETE FROM grants WHERE login = ?').run(login),
token(login: string) {
if (!inflight.has(login)) inflight.set(login, token(login).finally(() => inflight.delete(login)));
return inflight.get(login)!;
},
};
}
export type Grants = ReturnType<typeof grants>;
Loading
Loading