diff --git a/.env.example b/.env.example index d558c9ebc5..90f9340aca 100644 --- a/.env.example +++ b/.env.example @@ -175,19 +175,19 @@ GITTENSORY_REVIEW_DRAFT=false # # 1024-dimensional (e.g. bge-m3 or mxbai-embed-large via Ollama). # # Used only when RAG is enabled (GITTENSORY_REVIEW_RAG + allowlist). -# --- Gittensory Orb (#1255; opt-in fleet-calibration export) --- -# Orb is the central collector + analytics that aggregates anonymized gate-calibration data UP from -# self-hosted instances. There is NO separate Orb GitHub App and NO setup wizard: your existing main App -# already records de-noised outcomes (merged/closed + reversals) locally — flip ORB_ENABLED to ship an -# anonymized signal to gittensory's collector. That's it: no second App, no extra secret, no wizard. +# --- Gittensory Orb (#1255; ALWAYS-ON fleet-calibration telemetry) --- +# TELEMETRY NOTICE: running this self-hosted image contributes anonymized gate-calibration data to +# gittensory's central collector. This is ON BY DEFAULT and has no opt-out flag — it is part of the +# self-hosting contract: install the GitHub App, and your instance reports fleet-calibration signal so the +# gate can be tuned from real outcomes across all self-hosters. It activates automatically once your App is +# configured (no App = nothing is sent). There is NO separate Orb App and NO setup wizard. # -# SECURITY MODEL (this image is self-hosted by many independent maintainers): -# • The image bakes NO secrets. repo/PR identifiers are HMAC-anonymized with YOUR own ORB_WEBHOOK_SECRET -# (a stable per-instance string), so even gittensory (running the collector) can never de-anonymize them. -# • Export carries NO shared key. The collector accepts the batch as untrusted, rate-limited, aggregate-only -# telemetry. Nothing in the container, if leaked, can compromise the collector, other operators, or any App. -# ORB_ENABLED=false # master switch: set to true to export fleet-calibration signal (default off) -# ORB_WEBHOOK_SECRET= # the per-instance HMAC key used to anonymize repo/PR identifiers -# ORB_AIR_GAP=false # set to true to compute locally but never send to the collector +# WHAT IS SENT (per resolved PR, hourly): the gate verdict, the realized outcome (merged/closed), a reversal +# flag, a bucketed reason category, and cycle time. NEVER sent: repo/owner/PR names, commit SHAs, code, +# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with a DEDICATED key derived from YOUR +# OWN App private key (GITHUB_APP_PRIVATE_KEY) — high-entropy and independent of your webhook secret, so even +# gittensory (running the collector) can never de-anonymize them. +# The export carries no shared key; the collector treats it as untrusted, rate-limited, aggregate-only data. +# ORB_AIR_GAP=false # air-gapped/OFFLINE deployments only: compute locally, never send # ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names) # ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own) diff --git a/src/selfhost/orb-collector.ts b/src/selfhost/orb-collector.ts index e7c16c79c2..2ecada2795 100644 --- a/src/selfhost/orb-collector.ts +++ b/src/selfhost/orb-collector.ts @@ -3,17 +3,23 @@ // engine's outcomes-wire. This ships an anonymized, reversal-aware signal UP to gittensory's central // collector so the gate can be calibrated across the whole self-host fleet. // -// ORB_ENABLED=true — activates export (off by default) +// Export is ALWAYS ON once the GitHub App is configured (the fleet-telemetry contract of self-hosting) — +// there is no opt-out flag. It self-gates on a configured App private key (no App → no review data to +// export anyway) and anonymizes with a DEDICATED, per-instance secret generated once and persisted in +// system_flags (never the App private key or the webhook-verification secret — key separation). // ORB_COLLECTOR_URL= — endpoint (default: gittensory's hosted collector) -// ORB_AIR_GAP=true — keep everything local, never send externally +// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send // ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true) // // No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed -// reason category + cycle time, with repo/PR identifiers HMAC'd by THIS instance's own secret (the collector -// holds no instance secret, so it can never de-anonymize). -import { createHash, createHmac } from "node:crypto"; +// reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it +// can never de-anonymize). +import { createHash, createHmac, randomBytes } from "node:crypto"; import { incr } from "./metrics"; +/** Key under which the per-instance anonymization secret is persisted in system_flags. */ +const ANON_SECRET_FLAG = "orb:anon_secret"; + /** One de-noised, resolved-PR row read from review_audit (the join below). */ interface FleetRow { project: string; // repo full name (review_audit.project) @@ -55,6 +61,33 @@ function hmacField(value: string, secret: string): string { return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24); } +/** + * The instance's DEDICATED anonymization secret: a 256-bit random key generated once and persisted in + * system_flags, then reused on every export. Stable across restarts so a repo/PR always hashes the same + * way (the collector can dedup), per-instance, and SINGLE-PURPOSE — never the App private key or the + * webhook-verification secret (key separation). The collector never holds it, so it cannot de-anonymize. + */ +export async function getOrCreateAnonSecret(db: D1Database): Promise { + const read = async (): Promise => { + const row = await db + .prepare(`SELECT value FROM system_flags WHERE key = ?`) + .bind(ANON_SECRET_FLAG) + .first<{ value: string }>(); + return row?.value; + }; + const existing = await read(); + if (existing) return existing; + const generated = randomBytes(32).toString("hex"); // 256-bit, 64 hex chars + // Race-safe across instances sharing a Postgres DB: OR IGNORE keeps the first writer's key; the re-read + // returns whichever value won, so every instance converges on the same secret. + await db + .prepare(`INSERT OR IGNORE INTO system_flags (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`) + .bind(ANON_SECRET_FLAG, generated) + .run(); + /* v8 ignore next -- a row always exists after INSERT OR IGNORE, so the ?? fallback is unreachable */ + return (await read()) ?? generated; +} + /** Map the gate's free-text reasonCode to a fixed, low-cardinality category — done at the source so the raw * (possibly repo-specific) reason string never leaves the instance. */ export function bucketReasonCode(summary: string | null | undefined): string { @@ -69,12 +102,6 @@ export function bucketReasonCode(summary: string | null | undefined): string { return "other"; } -/** Returns true only when Orb export is explicitly enabled. */ -export function orbEnabled(): boolean { - const v = (process.env.ORB_ENABLED ?? "").toLowerCase(); - return v === "true" || v === "1" || v === "yes"; -} - // Latest gate_decision + latest pr_outcome per target_id, plus any reversal — portable (window functions + // CASE, no SQLite-only bare-column-with-MAX) so it runs on the self-host SQLite OR Postgres backend. const FLEET_QUERY = ` @@ -122,17 +149,22 @@ function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null { /** * Export newly-resolved PR outcomes (since this instance's watermark) to the central collector. Reads from - * review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor. - * Returns the number of events exported (0 if air-gap, disabled, or nothing new). + * review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor. Always on. + * Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new). */ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch): Promise { - if (!orbEnabled()) return 0; + // Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call. if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0; - // gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with THIS - // instance's own ORB_WEBHOOK_SECRET, and the collector accepts the batch as untrusted, rate-limited telemetry. + // No App configured → no review data to export anyway. Gate export on the App being set up. + if (!(process.env.GITHUB_APP_PRIVATE_KEY ?? "")) return 0; + + // gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with this + // instance's DEDICATED anonymization secret (a 256-bit random key generated once and persisted in + // system_flags — see getOrCreateAnonSecret), single-purpose and never the App key, so the collector + // (which never holds it) can never de-anonymize them. const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest"; - const secret = process.env.ORB_WEBHOOK_SECRET ?? ""; + const secret = await getOrCreateAnonSecret(db); const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false"; const instance = instanceId(); diff --git a/src/server.ts b/src/server.ts index d04c6f9c0c..921595c51b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,7 +14,7 @@ import worker from "./index"; import { processJob } from "./queue/processors"; import { createSelfHostAi } from "./selfhost/ai"; import { credentialsToEnv, exchangeManifestCode, renderSetupPage } from "./selfhost/setup-wizard"; -import { orbEnabled, exportOrbBatch } from "./selfhost/orb-collector"; +import { exportOrbBatch } from "./selfhost/orb-collector"; import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter"; import { readiness } from "./selfhost/health"; import { gauge, incr, observe, renderMetrics } from "./selfhost/metrics"; @@ -321,16 +321,14 @@ async function main(): Promise { ); }, intervalMs); - // Orb hourly export — batch-send pending outcome signals to the central collector. - // No-op when ORB_ENABLED is not set or ORB_AIR_GAP=true. - if (orbEnabled()) { - const runExport = () => - exportOrbBatch(backend.db) - .then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); }) - .catch((error) => console.error(JSON.stringify({ level: "error", event: "selfhost_orb_export_error", error: error instanceof Error ? error.message : "unknown error" }))); - void runExport(); // flush any pending events from a previous run at startup - setInterval(runExport, 3_600_000); // then hourly - } + // Orb fleet-telemetry export — ALWAYS ON (the fleet-calibration contract of self-hosting). Self-gates + // inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true. + const runOrbExport = () => + exportOrbBatch(backend.db) + .then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); }) + .catch((error) => console.error(JSON.stringify({ level: "error", event: "selfhost_orb_export_error", error: error instanceof Error ? error.message : "unknown error" }))); + void runOrbExport(); // flush any pending events at startup + setInterval(runOrbExport, 3_600_000); // then hourly // Graceful shutdown: stop accepting HTTP, let the queue finish, close the backend. let shuttingDown = false; diff --git a/test/unit/selfhost-orb-collector.test.ts b/test/unit/selfhost-orb-collector.test.ts index 776b1fa5c8..115734a5ff 100644 --- a/test/unit/selfhost-orb-collector.test.ts +++ b/test/unit/selfhost-orb-collector.test.ts @@ -1,7 +1,7 @@ import { DatabaseSync } from "node:sqlite"; import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter"; -import { bucketReasonCode, exportOrbBatch, orbEnabled } from "../../src/selfhost/orb-collector"; +import { bucketReasonCode, exportOrbBatch, getOrCreateAnonSecret } from "../../src/selfhost/orb-collector"; import { resetMetrics, renderMetrics } from "../../src/selfhost/metrics"; /** In-memory DB with the review_audit + orb_export_cursor tables the exporter reads. */ @@ -18,6 +18,10 @@ function makeDb(): D1Database { instance_hash TEXT PRIMARY KEY, last_exported_at TEXT NOT NULL DEFAULT '2000-01-01T00:00:00Z', updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ); + CREATE TABLE system_flags ( + key TEXT PRIMARY KEY, value TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ); `); return createD1Adapter(driver); } @@ -44,32 +48,43 @@ describe("bucketReasonCode()", () => { }); }); -describe("orbEnabled()", () => { - afterEach(() => { delete process.env.ORB_ENABLED; }); - it("true only for truthy values", () => { - for (const v of ["true", "1", "Yes"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(true); } - for (const v of ["", "false", "no"]) { process.env.ORB_ENABLED = v; expect(orbEnabled()).toBe(false); } - delete process.env.ORB_ENABLED; expect(orbEnabled()).toBe(false); +describe("getOrCreateAnonSecret()", () => { + it("generates a 256-bit (64 hex char) dedicated secret on first use and persists it", async () => { + const db = makeDb(); + const secret = await getOrCreateAnonSecret(db); + expect(secret).toMatch(/^[0-9a-f]{64}$/); + const row = await db.prepare(`SELECT value FROM system_flags WHERE key = 'orb:anon_secret'`).first<{ value: string }>(); + expect(row?.value).toBe(secret); // persisted, so it survives restarts + }); + + it("reuses the persisted secret on subsequent calls (stable → collector dedup holds)", async () => { + const db = makeDb(); + const first = await getOrCreateAnonSecret(db); + const second = await getOrCreateAnonSecret(db); + expect(second).toBe(first); + expect(first).not.toBe(process.env.GITHUB_APP_PRIVATE_KEY); // never the App private key }); }); -describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-aware signal", () => { +describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => { beforeEach(() => { resetMetrics(); - process.env.ORB_ENABLED = "true"; - process.env.ORB_WEBHOOK_SECRET = "test-secret"; + (process.env as NodeJS.Dict).GITHUB_APP_PRIVATE_KEY = "test-private-key"; // gates export (App configured); not the anon key process.env.ORB_APP_ID = "555"; process.env.ORB_ANONYMIZE = "true"; delete process.env.ORB_AIR_GAP; delete process.env.ORB_COLLECTOR_URL; }); afterEach(() => { - for (const k of ["ORB_ENABLED", "ORB_WEBHOOK_SECRET", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict)[k]; + for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict)[k]; }); - it("returns 0 when disabled", async () => { - process.env.ORB_ENABLED = "false"; - expect(await exportOrbBatch(makeDb(), 200, async () => new Response(null, { status: 200 }))).toBe(0); + it("returns 0 when the App private key is not configured (App not set up → nothing to export)", async () => { + delete (process.env as NodeJS.Dict).GITHUB_APP_PRIVATE_KEY; + const db = makeDb(); + await audit(db, "o/r", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z"); + await audit(db, "o/r", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z"); + expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0); }); it("returns 0 in air-gap mode", async () => { @@ -164,9 +179,8 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/); }); - it("falls back to GITHUB_APP_ID for the instance id and applies secret/anonymize defaults when ORB_* are unset", async () => { + it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when ORB_* are unset", async () => { delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID - delete process.env.ORB_WEBHOOK_SECRET; // → secret defaults to "" delete process.env.ORB_ANONYMIZE; // → defaults to "true" (process.env as NodeJS.Dict).GITHUB_APP_ID = "999"; const db = makeDb();