From c7463134e2b698451d5cbc6fcd04de7681ff789d Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:59:55 -0700 Subject: [PATCH] fix(orb): require telemetry export opt-in --- .env.example | 24 ++++++++++++------------ src/selfhost/orb-collector.ts | 17 ++++++++++------- src/server.ts | 4 ++-- test/unit/selfhost-orb-collector.test.ts | 20 +++++++++++++++++--- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index 0b3ab4ba34..356f66eb2f 100644 --- a/.env.example +++ b/.env.example @@ -179,19 +179,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; 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. +# --- Gittensory Orb (#1255; opt-in fleet-calibration telemetry) --- +# TELEMETRY NOTICE: Orb can contribute anonymized gate-calibration data from self-hosted instances to +# gittensory's central collector, but outbound export is OFF unless you explicitly opt in with ORB_ENABLED. +# It also requires the GitHub App private key to be configured (no App = nothing is sent). There is NO +# separate Orb App and NO setup wizard. # -# 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. +# WHAT IS SENT (per resolved PR, hourly, only when ORB_ENABLED is truthy): the gate verdict, the realized +# outcome (merged/closed), a reversal flag, a bucketed reason category, and cycle time. NEVER sent by default: +# repo/owner/PR names, commit SHAs, code, diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized +# with a DEDICATED per-instance key stored in system_flags, so 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_ENABLED=false # set true/1/yes/on to opt in to outbound Orb export # 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 2ecada2795..1157327f89 100644 --- a/src/selfhost/orb-collector.ts +++ b/src/selfhost/orb-collector.ts @@ -3,10 +3,11 @@ // 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. // -// 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). +// Export is opt-in: set ORB_ENABLED=true after reviewing the telemetry contract. It also 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_ENABLED=true — opt in to fleet-calibration export (default: false) // ORB_COLLECTOR_URL= — endpoint (default: gittensory's hosted collector) // ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send // ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true) @@ -149,11 +150,13 @@ 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. Always on. - * Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new). + * review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor. + * Returns the number of events exported (0 if disabled, air-gapped, the App isn't configured, or nothing new). */ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch): Promise { - // Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call. + if (!/^(1|true|yes|on)$/i.test(process.env.ORB_ENABLED ?? "")) return 0; + + // Air-gapped/offline deployments may suppress the outbound call even after opting in. if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0; // No App configured → no review data to export anyway. Gate export on the App being set up. diff --git a/src/server.ts b/src/server.ts index eec227a28b..9adcf489fb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -355,8 +355,8 @@ async function main(): Promise { ); }, intervalMs); - // 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. + // Orb fleet-telemetry export — opt-in inside exportOrbBatch; also 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 })); }) diff --git a/test/unit/selfhost-orb-collector.test.ts b/test/unit/selfhost-orb-collector.test.ts index 115734a5ff..af3001d6d6 100644 --- a/test/unit/selfhost-orb-collector.test.ts +++ b/test/unit/selfhost-orb-collector.test.ts @@ -66,17 +66,31 @@ describe("getOrCreateAnonSecret()", () => { }); }); -describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => { +describe("exportOrbBatch() — opt-in; reads review_audit, ships anonymized reversal-aware signal", () => { beforeEach(() => { resetMetrics(); (process.env as NodeJS.Dict).GITHUB_APP_PRIVATE_KEY = "test-private-key"; // gates export (App configured); not the anon key + process.env.ORB_ENABLED = "true"; 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 ["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]; + for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_ENABLED", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict)[k]; + }); + + it("returns 0 unless Orb export is explicitly enabled", async () => { + delete process.env.ORB_ENABLED; + 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); + + for (const off of ["", "false", "no", "0", "off"]) { + process.env.ORB_ENABLED = off; + expect(await exportOrbBatch(db, 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 () => { @@ -179,7 +193,7 @@ describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized r expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/); }); - it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when ORB_* are unset", async () => { + it("falls back to GITHUB_APP_ID for the instance id and applies the anonymize default when optional ORB_* are unset", async () => { delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID delete process.env.ORB_ANONYMIZE; // → defaults to "true" (process.env as NodeJS.Dict).GITHUB_APP_ID = "999";