Security notes
Dated notes of what was actually exposed, what was not, and what changed. A note here is a record, not a promise that nothing else can be wrong.
2026-09-25 — Two read endpoints answered without a key
The key middleware was mounted on both routes and still let an anonymous caller through, because that middleware identifies a key and does not, by itself, refuse the absence of one.
Window
GET /api/v1/timeline and GET /api/v1/timeline/summary: 2026-03-14 → 2026-09-25. The route file’s first commit is fc64c74 (2026-03-14); the anonymous refusal landed in 9272675 (2026-09-25). GET /api/v1/preflight-logs: 2026-03-19 (fa3f294) → the same 2026-09-25 refusal. Counting the timeline as 194 days understates it. pr_analyses, the table that route read, was not on the 90-day sweep, so an anonymous caller could read the whole history of that table for any repository name, not a 194-day slice (src/db/retention.js, comment dated 2026-09-25).
What was readable
Decision metadata, from the response the handler built (src/routes/timeline.js): verdict, risk score and level, breaking-change count, the top pattern’s name, a semver suggestion, pull-request number, a 7-character commit SHA, timestamp, and the repository name the caller supplied. preflight-logs added a submitted spec URL, a truncated user-agent, a hashed IP, and a response time. Path and field names were not in that JSON: the query loaded detected_patterns and the response emitted only the pattern name.
What was not readable
Spec or diff body. The API-key hash column exists on pr_analyses (src/db/migrate.js) and was not in the SELECT or the response. No tenant id: that table has no tenant column. No user account and no email.
Whether anyone called it
Not decidable from data the route stored. The handler writes a log line only when the query throws. It does not record a successful anonymous read. How many rows belonged to someone other than us was not counted for this note.
The fix
- Anonymous requests to
/api/v1/timeline, /api/v1/timeline/summary, and /api/v1/preflight-logs receive 401 (authentication_required) since 2026-09-25. On the timeline and the timeline summary that 401 is the absence of a key. Any key still read any repository by name until 2026-10-02.
- A keyed
GET /api/v1/ledger returns rows only for repositories bound to that key’s tenant (src/routes/ledger.js, join on repo_bindings). An anonymous ledger read stays open and returns an allowlist: verdict, risk level, break count, timestamp, and a per-row digest. Repository, pull request, commit, people, and override reason are not in that view.
- Stored request-log IPs are salted (
sha256s: in src/util/hash-ip.js). The previous sha256: values were unsalted and, for IPv4, reversible; the migration nulls them. Whether the live deploy pins CODERIFTS_IP_HASH_SALT is not measured here. Unset, the process generates a salt at boot.
pr_analyses is on the 90-day retention sweep. The sweep does not rewrite rows that were already read.
What stayed open on purpose
GET /api/v1/timeline/public still answers without a key (src/routes/public-timeline.js). The query filters on the repository name the caller supplies and on a day window. It does not check whether that repository is public. A probe on 2026-09-26 of five private repositories this org owns, and of one invented name, each returned HTTP 200 and zero rows. A public repository returned 50 rows in the same window, including verdict, risk score, pattern name, and commit SHA. This probe did not return a private row. It also did not prove a private repository with stored analyses would be withheld.
Structural lesson
Authenticated is not authorized. The middleware sets a flag and calls next on a request with no key, so a route can serve both a public mode and a keyed mode. Mounting it is not the same fact as requiring a key. A wrong key was refused; no key was accepted, which is why the routes did not look broken. The timeline test mocked a stricter gate than the code: it rejected a missing header and never set the flag the real middleware sets, so the suite could not ask what an anonymous call received.
2026-10-02 — GET /api/v1/ledger, what an anonymous caller could read, and when
1. Full rows without a key: 2026-03-14 → 2026-09-25 14:26 UTC
The key middleware let a request without a key through, and the route did not check. From 2026-03-14 17:00 (-04:00) a caller had to name a repository and received its full rows. From 17:15 the same day the filter was optional, and a caller without a key received every row of every repository: repository, pull-request number, PR author, commit SHA, verdict, risk and omega scores, break count, policy rule and action, who approved or overrode a decision and why, and timestamps (22 fields from 2026-03-15). The table has no tenant column, so there was no tenant filter to apply. The code is dated 2026-03-14; our deployment records reach back to 2026-06-21, and the oldest record already served it.
What the table held before the fix: 2,257 rows. 1,649 came from anonymous analyses and carry no repository and no person, except one approval recorded under our own test address on 2026-03-14. 83 belong to our own repositories. 525 belong to one third-party repository, which is public on GitHub. Each of those 525 rows names a PR author (7 distinct GitHub logins, 175 of the rows authored by a bot). None of them records an approver, an overrider or an override reason. PR authors and commit SHAs of a public repository are public on GitHub; our verdicts and scores on its pull requests were not.
2. Six fields, and a repository-name oracle: 2026-09-25 14:26 UTC → 2026-10-02 22:31 UTC
An anonymous caller received only verdict, risk level, break count, timestamp, a row number and a per-row digest, with no repository and no person. A key read only repositories bound to its tenant. But a caller who supplied a repository name could still filter by it, and so learn whether that repository had history with us and read its verdicts and timestamps.
The third-party rows did not end on 2026-09-25. They run through 2026-09-30. Of the 17 rows after 2026-09-25, those six fields were visible without a key, and a caller who knew the name could also query their verdicts and timestamps with the filter until 2026-10-02 22:31 UTC.
3. Closed: since 2026-10-02 22:31 UTC
A repository named without a key is served only when it is observed public; anything else answers like a name with no history. A key still reads only its tenant's bound repositories.
Whether anyone read the ledger before 2026-09-25 cannot be measured. Our request logs do not reach back before 2026-09-25, and the route never recorded successful reads. In the window that can be measured (2026-09-25 → 2026-10-02), no caller read a repository that was neither their own nor public.
Timeline and analytics, by route
From 2026-09-25 (ledger keyless repository filter), 2026-03-14 (analytics/stability), 2026-03-14 (timeline) and 2026-03-15 (timeline/summary) until 2026-10-02, the ledger's keyless repository filter, analytics/stability, and timeline/timeline-summary (with any key) could return another tenant's repository decision history. Fixed on 2026-10-02: a history is readable by name only when the repository is public or bound to the caller's tenant; an unreadable repository answers like an unknown one. In the retained logs (2026-09-25 to 2026-10-02) no such read occurred; before 2026-09-25 it cannot be measured.
Commits, from git log -S and blame on the parent of the fix. Ledger allowlist plus the caller-supplied repo filter: b235243, 2026-09-25 16:26 +0200. The if (repo) line is older (02142dc, 2026-03-14) and then selected full rows, before this allowlist. analytics/stability queried WHERE repo = $1 from f8cfcef, 2026-03-14 11:32 -0400, and that file named no tenant until the fix. Timeline queried WHERE repo = $1 from 94c1f0b, 2026-03-14 13:25 -0400; timeline/summary from 4e796fa, 2026-03-15 05:15 -0400. 9272675 (2026-09-25) added the anonymous 401 and left the name filter. The fix commit is 5b856d5, 2026-10-03 00:31 +0200 (2026-10-02 22:31 UTC). Measured live after the fix on 2026-10-02: a keyless ledger request filtered to a repository that is not observed public, and the same request to analytics/stability, both answer 404. The read count is the 2026-10-02 ledger GET note.
2026-09-26 — A bad signature could still read as currently authorized
POST /api/v1/verify-receipt, and the MCP tool verify_receipt which calls it, computed currently_authorized from the receipt payload even when the signature did not verify. valid stayed false. Found 2026-09-26 on the mounted app. Fixed the same day in 5bbac9c. Live: engine_build_id 5bbac9c6af8b, and /health uptime puts that process start at about 08:34 UTC.
Window
2026-07-28 → 2026-09-26, about 08:34 UTC. The start is commit a02a2bb, the day the server-side authorization layer landed on verify-receipt. When that commit was deployed was not measured.
Whether anyone could act with it
Not measured. The response always said valid: false. A caller who refused on valid did not proceed. A caller who proceeded because currently_authorized was true could have. How many such calls happened is not counted here. The agent guard, the contract-gate action, the gateway verifier, and the offline verifiers do not take this answer from the server route.
The fix
Authorization is evaluated only after the signature verifies. Otherwise currently_authorized is false and authz_reason is signature_not_verified.
In the window we can measure (2026-08-24 → 2026-09-26, 67 verify calls), no call received currently_authorized: true without a verified signature. The period 2026-07-28 → 2026-08-24 predates verify telemetry and cannot be reconstructed.
P40 — 2026-10-04
P40 — 2026-10-04. Between the P32 fix and 2026-10-04, an incomplete analysis in authorize mode could come out as a signed ALLOW. The clearest case: an OpenAPI 3.1 webhook that lost a required field received CONTINUE with a receipt, while the same pair in analyze mode answered ANALYSIS_FAILED. The cause was that a 3.1 spec was converted to 3.0.3 before diffing, which removed webhooks and similar sections, and the authorize path did not read the resulting coverage gap. Since 2026-10-04 an incomplete analysis in authorize mode is ANALYSIS_FAILED with the gap named, a 3.1 spec is diffed as written, and the conversion is only a degraded fallback. We found this in our own measurement. Since 2026-09-19, every authorize decision on record was issued to our own tenants; no other tenant received one. A degraded ALLOW from before 2026-10-04 cannot be told apart in our records.
The same gap existed on the MCP manifest path until 2026-10-04 (P49): an MCP tool schema that referenced itself fell back to a path-only diff, and that incomplete analysis was not marked as such. It is now ANALYSIS_FAILED with the gap named.
P47 — 2026-10-04
P47 — 2026-10-04. Until 2026-10-04 the App read .coderifts.yml from the pull request's head, so a pull request could change the policy it was judged by: adding an ignore rule turned its own break into ALLOW and its check from failure into success. A reviewer whose commits were in the head, or a pull request without a recorded author, could also count as an independent approval. Since 2026-10-04 the decision keys of .coderifts.yml come from the base branch, a change to them in the head is shown and not applied, the authors and committers of the head commits cannot approve, and a missing author means no approval counts. We found this in our own measurement; we know of no external repository where it was used.
P61 — 2026-10-06
P61 — 2026-10-06. Before coderifts 8.7.3, the CLI's offline commands did not run CodeRifts' analysis. What it affected: coderifts diff (without --cloud or --gate) and publish-gate without a key ran a separate open-source engine (the openapi-diff package), and adopt reported the engine in a non-production mode. What we measured (17 OpenAPI changes, 2026-10-06): the local diff reported should_block: false for all 17; it could not read OpenAPI 3.1 or 3.2; through --ci it passed one change our server stops (a shrunk enum). adopt reported a different decision from the server on 8 of 31 changes. Not affected: the server-side preflight, the GitHub App and the merge gate. What we cannot say: how many runs used the older path. The CLI sends nothing home. npm counts 11,732 downloads of the coderifts package between 2025-04-05 and 2026-10-05 (4,056 in the 30 days to 2026-10-04), including our own release checks and CI caches, and a download is not a run. Since coderifts 8.7.3, published on npm at 2026-10-06 21:10 UTC, coderifts diff without --cloud or --gate and publish-gate without a key run the server's analysis and agree with it on those 17 OpenAPI changes.
P62 — 2026-10-06
P62 — 2026-10-06. From 2026-06-16 to 2026-10-06 09:56 UTC, the tool-schema registry (POST and GET /api/v1/registry/tool-schemas, /check, and the incidents) identified the tenant by the first 12 characters of the API key: cr_live_ plus 4 hex digits, 65,536 possible values. Two keys that received the same prefix could list each other's registered tools, including the alert webhook URL, compare a schema with the other's on /check, fire the other's webhook, read the other's incidents, and overwrite the other's schema. Since 2026-08-23 a new key was regenerated while its prefix was already in the store, up to 5 times. That did not check keys already issued. A prefix still taken after those attempts was stored anyway. A revoked key's prefix could be issued again. On 2026-10-06, deployed at 09:56 UTC, the registry was bound to the key's full identity, and the old rows were moved. At the fix the registry held 3 schemas and 1 incident. Each of the three old prefixes belonged to exactly one live key. None was shared or orphaned. What we cannot say: whether a revoked key's prefix had already been issued again, because a revoked key leaves no trace.
P65 — 2026-10-06
P65 — 2026-10-06. Local hooks and the contract-gate Action treated some MCP client-configuration names as contract files and sent the file's whole text, including any tokens in it, to preflight, over TLS. coderifts claude-hook did that from commit bd82bba on 2026-09-01 13:09 UTC, published in coderifts 8.0.0 at 2026-09-01 16:24 UTC, for a file whose name contains mcp and ends in .json, .yaml, or .yml. That includes mcp.json, .mcp.json, .cursor/mcp.json, .vscode/mcp.json, mcp_settings.json, and cline_mcp_settings.json. It did not match claude_desktop_config.json. @coderifts/agent-hooks matched a path ending in mcp.json from its first release, 0.2.0, npm 2026-09-13 16:05 UTC, and matched any JSON path whose name contains mcp from 0.3.0, npm 2026-10-03 19:50 UTC. The Claude plugin's mod did that from api-governance 1.2.0, commit 2026-10-03 12:44 UTC, for a path whose name contains mcp. The contract-gate Action's classifier matched mcp.json, a path ending in /mcp.json, and mcp_settings.json from the repository's first commit on 2026-07-26 17:34 UTC. The first published release that carries that classifier is v0.7.0, published 2026-09-01 12:43 UTC. That regex did not match .mcp.json, claude_desktop_config.json, or cline_mcp_settings.json. The text was not stored and was not logged. The request log records the method, the path, the status, the duration, and a key prefix, and not the body. On a keyed authorize, decision_authorized_paths keeps the path and the SHA-256 of the text after the file, for 90 days, and not the text. A read of 30 days of live logs counted 0 lines with a token shape. Until the fix, the stored rows that name a client-configuration file are 2, and the rows that carry only the fixed id api are at most 12. All of those rows are our own tenant. They came from our own tests. No other customer's configuration arrived. For the mod and for coderifts claude-hook the stored path uses the fixed id api, so one of those rows cannot be told from a real tool manifest. Nothing here describes a network between the caller and us. Since the fixes, an MCP client configuration is not a contract at each entry point: api-governance 1.2.7 (tag v1.2.7, 2026-10-06 12:49 UTC), coderifts 8.7.3 (npm 2026-10-06 21:10 UTC), @coderifts/agent-hooks 0.3.2 (npm 2026-10-06 19:55 UTC), @coderifts/contract-path 1.2.0 (npm 2026-10-06 19:58 UTC), contract-gate v0.11.3 (published 2026-10-06 19:50 UTC; the v0 tag is the same commit 97d4cea), and the App required check (commit 4d231d9, deployed 2026-10-06 13:26 UTC). The fix reaches an install only after that install is updated.
P65c — 2026-10-08
P65c — 2026-10-08. A plain mcp.json is decided by its content. The P65 fix (2026-10-06) took every file named mcp.json as an MCP client configuration, by name. That stopped the leak, but it also stopped checking a server's own tool manifest of that name, coderifts.com/mcp.json included. Since the dates below, the names that are only ever a client configuration stay skipped and are never read: .mcp.json, .cursor/mcp.json, .vscode/mcp.json, claude_desktop_config.json and (cline_)mcp_settings.json. Any other mcp.json is read where it already is and decided by its content: mcpServers or servers and no tools is a client configuration, never sent; tools, any other shape, or text that does not parse, is checked as a contract. Fixed in: @coderifts/contract-path 1.3.0 (npm 2026-10-08 07:47 UTC); @coderifts/agent-hooks 0.3.3 (npm 2026-10-08 08:12 UTC); contract-gate v0.11.4 (published 2026-10-08 08:15 UTC, commit 9359e7fdb8af386fa74266e3b48252c2b1cdf6f7; the v0 tag points at that same commit); CLI 8.7.4 (npm 2026-10-08 08:26 UTC, gitHead 8bcb356b75331ecd5e6b2daff7fd3c78d182d03e); the GitHub App required check (live no later than 2026-10-08 09:50 UTC (production deployment of def4cec18782, which contains the content-rule commit 64e904e)); api-governance 1.2.8 (tag v1.2.8, commit 9411e6bb52192155a624224f124cc49a9f1aa2c6, tagger 2026-10-08 20:18 UTC). From 2026-10-06 until, at the latest, the time named on each line, that entry point did not check a contract change in a file named mcp.json. The P65 measurement is unchanged: no client configuration was sent after 2026-10-06. Each fix reaches an install only after it is updated: a global CLI, agent-hooks, the plugin, and an Action pinned by SHA. The v0 tag points at the v0.11.4 commit.
Check-run 113511365478 (CodeRifts / issuer) decided a change to coderifts.com's mcp.json on closed draft pull request coderifts/website#2. The pull request head file is 68,483 bytes. The live file is 81,018 bytes. The check started 2026-10-08 19:54:53 UTC and completed 2026-10-08 19:54:56 UTC, 3 seconds. The title is STOP · merge · sha256:14e93d4…4cef5d · 2026-10-08T23:54:56Z · ENDPOINT_REMOVAL. That output does not name an engine_build_id.
P65d — 2026-10-09
P65d — 2026-10-09. A plain mcp.json that is both a client configuration and a tool manifest, or that does not parse, is not read into a contract and is not sent. By name, .mcp.json, .cursor/mcp.json, .vscode/mcp.json, claude_desktop_config.json and (cline_)mcp_settings.json are client configurations and are never read. Any other mcp.json is read where it is and decided by its content. mcpServers or servers and no tools is a client configuration, counted as no file, and never sent. Anything else is a contract: a tools key, an object without a server list, or a parsed value that is not an object. Both at once, or text that does not parse, is not read into a contract and is not sent. The hooks ask. The gate and the App give a decided red with one sentence. For both at once the sentence is the file's path followed by "is both an MCP client configuration and a tool manifest; it is not read or sent — split the server list and the tool manifest into separate files." For text that does not parse the sentence is the file's path followed by "does not parse as JSON; it is not read or sent — a client configuration may hold credentials. Make it valid JSON (no comments) to have it checked." The same rule is in the contract-gate Action v0.11.5 (published 2026-10-09 14:25 UTC, commit 8542c07ad021b41e062278d64aa9e88473f004ff; the v0 tag points at that commit), the App webhook on the production deployment of 3a9974895d0a (succeeded 2026-10-09 15:22 UTC), the coderifts CLI 8.7.5 hook (npm 2026-10-09 15:07 UTC, tag cli-v8.7.5, commit aecc300ebab998f52433dd69b56f078b85f330e5), @coderifts/agent-hooks 0.3.4 (npm 2026-10-09 14:31 UTC) and later, and the Claude Code plugin mod api-governance 1.2.9 (tag v1.2.9, tagger 2026-10-09 16:22 UTC, commit 437e397ea67980d015f75b5a4a1952bc6aa0d6d0). @coderifts/contract-path 1.3.1 was published on npm at 2026-10-09 14:23 UTC. From the P65c time on each entry point (2026-10-08) until, at the latest, the time named for it above, a plain mcp.json that was both, or that did not parse, was checked as a contract, and its text was sent to the check. This round did not measure whether any such file was sent. Each fix reaches an install only after it is updated. The production deployment of 3a9974895d0a went inactive at 2026-10-09 16:08 UTC, when 5a02cb3ca4a1, which contains it, succeeded. A keyless analyze that removed a tool from an mcp_manifest, posted to https://app.coderifts.com/api/v1/preflight on 2026-10-09, returned BREAKS_DETECTED with engine_build_id 5a02cb3ca4a1. That call is not the mixed-file webhook.
Check-run 113892360938 (CodeRifts / issuer) on closed draft pull request coderifts/website#3 returned conclusion failure. The check started 2026-10-09 15:27:18 UTC and completed 2026-10-09 15:27:20 UTC, 2 seconds. The title is Not checked · mcp.json is both an MCP client configuration and a tool manifest; it is not read or sent — split the server list and the tool manifest into separate files. That is a decided red, not a timeout. The check does not name an engine_build_id. At that clock time the production deployment of 3a9974895d0a was the one in the success state. Measured by us on our own probe, not by an external user. The pull request was closed without merging.
@coderifts/agent-hooks 0.3.5 (npm 2026-10-09 16:30 UTC). The hook runs when invoked through a symlinked path, for example a package-manager layout. Before 0.3.5 it exited without a decision there. This affects the agent-hooks package only.
Package versions named here are tagged on release day.
P66 — 2026-10-12 · Keyless callers received signed receipts.
`POST /api/v1/verdict`, `/api/v1/action-verdict`, `/api/v1/diff`, the agent preflight and `mcp-diff` signed a `chain_receipt` for a caller without an API key (measured live: v2 receipt, kid `2026-07-k1`, `caller: "anon"`). A signed receipt was therefore no proof that a known caller asked. In addition, `contract-gate` and `@coderifts/receipt-verifier` accepted receipts below v4. Those versions do not bind the request envelope and carry no expiry. A decision could also be read by id without a key, across tenants. Fixed: receipts are issued only to verified callers. contract-gate 0.11.9 accepts only a v4 receipt bound to its envelope with a readable expiry. receipt-verifier 1.1.1 reports `RECEIPT_VERSION_UNSUPPORTED` for v<4 and `MALFORMED` for a non-string signed field. Decision lookup needs a key and returns only the caller's own tenant's rows. Affected: any check that treated a v1–v3 receipt, or a receipt with `caller: anon`, as authorization. Action: upgrade contract-gate and the verifier; re-run any verification that relied on a pre-v4 receipt.
P67 — 2026-10-12 · Protobuf changes the engine did not see.
A field written with options (`= 3 [deprecated = false];`) was not parsed, so removing it or changing its type answered ALLOW with a signed receipt. Comments were parsed as code: an rpc commented out was not "removed", and a commented message looked real. A package rename, enum renumbering, singular↔repeated, leaving a oneof and a new proto2 `required` field also answered ALLOW. Fixed in the engine; each of these is now a named breaking change.
P68 — 2026-10-12 · A malformed contract counted as analyzed.
A gRPC or AsyncAPI side that did not parse as a schema, or was not one ("hello", `{}`, a YAML list), was treated as an empty contract. A broken base hid real removals behind ALLOW; a broken head invented removals. Fixed: such a side is `ANALYSIS_FAILED`, which blocks. A failed analysis is not a result.
P69 — 2026-10-12 · Cross-tenant ledger writes and server-side fetches of caller URLs.
`POST /api/v1/ledger/:id/approve` and `/override` looked up a ledger entry by id alone, so a key from another tenant could approve or override it. Five routes fetched any URL the caller named, including link-local metadata (169.254.169.254), loopback and private ranges: `score-mcp`, `sdk-sim`, `hallucination-risk`, `notifications/test` and `shadow-fix/report`. `notifications/test` returned the target's response body. Fixed: ledger approve and override are scoped to repositories bound to the caller's tenant; a foreign id reads as not found. Every caller-URL fetch passes one guard: http(s) only, no credentials, every resolved address public, metadata names refused, each redirect re-checked. Response bodies are no longer echoed. Residual: DNS rebinding between the check and the connection.
P70 — 2026-10-12 · Renames and the reader's control plane.
The merge gate classified a renamed file by its new name only. A contract renamed with a breaking change read as a new artifact. A contract moved to `.bak` or into `vendor/`, or the gate's own workflow, `.coderifts.yml` or a signing key moved out of place, read as no change. The control-plane list missed `keyring/pinned-keys.json`, root `action.yml` and `.github/actions/**`. The reader could not send control-plane files, so its receipts bound an empty set. Fixed: a rename is a change of the class it leaves, the list covers the gate's trust and code paths, and the reader's control-plane artifacts are analysed when the request binds repository, base and head.
P71 — 2026-10-12 · Local hook fail-open branches.
The installed Claude Code hook command was `coderifts claude-hook`. Claude Code proceeds on every exit code other than 2, so with the binary missing (exit 127) or a crash (exit 1) every write went through, while `coderifts status` still said `Runtime PREVENTS`. A `--log` path with a space broke the command. A legacy-format decision such as `ESCALATE_TO_HUMAN` or lowercase `block` was treated as allow by the hook and the pre-push hook. The pre-push hook truncated a spec containing \c and put the API key on the curl command line. Self-protection did not cover `git config coderifts.specPathExclude` or `env.CODERIFTS_API_BASE` / `CODERIFTS_MONITORING_SINK_WIRED`. Fixed in coderifts 8.7.6: the command ends in `|| exit 2` and quotes its path (existing installs are upgraded by `coderifts hook install --claude`), `status` reports `BINARY_MISSING`, unknown decisions block, the pre-push hook sends the spec and key through 0600 files, and self-protection refuses these switches. Action: upgrade and re-run `coderifts hook install --claude`.