diff --git a/docs/self-hosting.md b/docs/self-hosting.md index baa9091cd1..a61cd1d4d4 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -175,6 +175,15 @@ turned on. Per-repo settings (autonomy, required approvals, protected paths) liv repository settings. The authoritative reference for all of these is [`docs/review-configuration.md`](./review-configuration.md). +**Container-private per-repo config (keep policy off the public repo).** `.gittensory.yml` lives in the repo, so +contributors can read it — and whoever can see the gate thresholds, autonomy, or label policy can game them. To +keep review policy private, set **`GITTENSORY_REPO_CONFIG_DIR`** to a mounted directory and drop one file per repo +named `{owner}__{repo}.yml` (lowercased, `/` → double underscore) — e.g. `jsonbored__metagraphed.yml`. When a file +exists for a repo the engine reads it **instead of** fetching the public `.gittensory.yml`, so the policy never +appears in contributor-facing previews. It uses the same schema (`gate:` / `settings:` / `review:` — autonomy, +labels, model/effort), is read fresh each review (edits apply immediately), and `.yaml` / `.json` are also +accepted. Unset ⇒ the public file is fetched exactly as before. + --- ## 6. Operations diff --git a/src/env.d.ts b/src/env.d.ts index aecb5bbba5..eb7ebd220a 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -84,6 +84,11 @@ declare global { /** Self-host instance-wide write switch: "dry-run" | "disabled" forces EVERY installation write to be * suppressed regardless of per-repo mode (the cloud→self-host parallel-run kill switch). Unset = live. */ SELFHOST_DEPLOYMENT_MODE?: string; + /** Self-host container-private per-repo config dir. When set, the focus-manifest loader reads + * `{dir}/{owner}__{repo}.{yml,yaml,json}` INSTEAD of the public `.gittensory.yml`, so review policy (gate, + * autonomy, labels, model/effort) is set privately and contributors can't read or game it. Unset ⇒ public + * fetch (cloud, or a self-host without the dir, is byte-identical to before). */ + GITTENSORY_REPO_CONFIG_DIR?: string; GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string; GITTENSORY_DRIFT_ISSUE_REPO?: string; GITTENSORY_DRIFT_ISSUE_TOKEN?: string; diff --git a/src/selfhost/private-config.ts b/src/selfhost/private-config.ts new file mode 100644 index 0000000000..48f3d72d6f --- /dev/null +++ b/src/selfhost/private-config.ts @@ -0,0 +1,38 @@ +// Container-private per-repo config (self-host). A self-host operator mounts a directory at +// GITTENSORY_REPO_CONFIG_DIR and drops one `{owner}__{repo}.yml` file per repo; the focus-manifest loader reads +// it INSTEAD of fetching the public `.gittensory.yml`, so review policy (gate, autonomy, labels, model/effort) is +// configured PRIVATELY and never exposed to contributors who could read and game the public file. Node-only — it +// is registered into the Workers-safe loader via setLocalManifestReader at boot (server.ts), so this module's fs +// import never reaches the Cloudflare bundle. +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { RepoFocusManifestFetcher } from "../signals/focus-manifest-loader"; + +/** Candidate filenames for a repo's private config, in priority order. The slug is the lowercased GitHub + * `owner__repo` (double underscore because `/` is not filename-safe) — e.g. `JSONbored/metagraphed` → + * `jsonbored__metagraphed.yml`. An invalid repo full name (no single interior slash) yields no candidates. */ +export function localConfigCandidates(repoFullName: string): string[] { + const slash = repoFullName.indexOf("/"); + if (slash <= 0 || slash === repoFullName.length - 1) return []; + const slug = `${repoFullName.slice(0, slash)}__${repoFullName.slice(slash + 1)}`.toLowerCase(); + return [`${slug}.yml`, `${slug}.yaml`, `${slug}.json`]; +} + +/** Build the container-local manifest reader over GITTENSORY_REPO_CONFIG_DIR, or null when the dir is unset/blank + * (⇒ the loader keeps fetching the public `.gittensory.yml`). Each lookup returns the first existing + * `{dir}/{owner}__{repo}.{yml,yaml,json}` file's text; null when none exist for the repo (⇒ the loader falls + * through to the public file). A read error on one candidate is swallowed so the next candidate is tried. */ +export function makeLocalManifestReader(dir: string | undefined): RepoFocusManifestFetcher | null { + const base = (dir ?? "").trim(); + if (!base) return null; + return async (repoFullName: string): Promise => { + for (const candidate of localConfigCandidates(repoFullName)) { + try { + return await readFile(join(base, candidate), "utf8"); + } catch { + // ENOENT / unreadable → try the next candidate + } + } + return null; + }; +} diff --git a/src/server.ts b/src/server.ts index 93ec58799f..fdfcb0faae 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,8 @@ import { createPgQueue } from "./selfhost/pg-queue"; import { createPgVectorize, initPgVectorize } from "./selfhost/pg-vectorize"; import { createSqliteQueue } from "./selfhost/sqlite-queue"; import { createSqliteVectorize } from "./selfhost/vectorize"; +import { makeLocalManifestReader } from "./selfhost/private-config"; +import { setLocalManifestReader } from "./signals/focus-manifest-loader"; import type { JobMessage } from "./types"; /** Resolve `_FILE` env vars (Docker secrets / multi-line keys) into `` at startup. */ @@ -157,6 +159,10 @@ function buildSqliteBackend(consume: (m: JobMessage) => Promise): Backend async function main(): Promise { loadFileSecrets(); + // Container-private per-repo config (self-host): register the GITTENSORY_REPO_CONFIG_DIR reader so the focus- + // manifest loader prefers a mounted `{owner}__{repo}.yml` over the public `.gittensory.yml` (review policy stays + // private). Unset dir ⇒ null reader ⇒ unchanged public-fetch behavior. + setLocalManifestReader(makeLocalManifestReader(process.env.GITTENSORY_REPO_CONFIG_DIR)); const startedAt = Date.now(); // The queue consumer captures `env`, assigned below (the first job only runs once an HTTP/cron event diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index 52d056381e..8bf67e1056 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -21,6 +21,18 @@ export const MANIFEST_FILE_CANDIDATES = [ */ export type RepoFocusManifestFetcher = (repoFullName: string) => Promise; +/** + * Optional container-private per-repo config reader (self-host GITTENSORY_REPO_CONFIG_DIR). When registered it + * takes priority over — and fully REPLACES — the public `.gittensory.yml` for the normal (non-preview) load, so a + * self-host operator sets review policy privately and contributors can't read or game it. Registered once at boot + * by the Node entry (server.ts); the filesystem access lives inside that injected closure, keeping THIS module + * Workers-safe. Unset (cloud, or a self-host without the dir) ⇒ behavior is byte-identical to the public fetch. + */ +let localManifestReader: RepoFocusManifestFetcher | null = null; +export function setLocalManifestReader(reader: RepoFocusManifestFetcher | null): void { + localManifestReader = reader; +} + /** * Fetch a maintainer-owned manifest file from the public GitHub raw endpoint. Network or HTTP * failures resolve to null so the loader falls back to deterministic signals. @@ -78,6 +90,13 @@ async function loadRepoFocusManifestWithCachePolicy( options: { fetcher?: RepoFocusManifestFetcher; maxAgeMs?: number; refresh?: boolean } = {}, cachePolicy: { publicOnly?: boolean } = {}, ): Promise { + // Container-private per-repo config (self-host) takes priority over the public `.gittensory.yml`: read fresh from + // local fs each call (cheap, no network) so operator edits apply immediately. NEVER consulted on the publicOnly + // (contributor-preview) path, and never persisted — so private policy can't leak into previews or the cache. + if (!cachePolicy.publicOnly && localManifestReader) { + const localRaw = await localManifestReader(repoFullName); + if (localRaw !== null) return parseFocusManifestContent(localRaw, "api_record"); + } const fetcher = options.fetcher ?? fetchRepoFocusManifestFile; const maxAgeMs = options.maxAgeMs ?? REPO_FOCUS_MANIFEST_MAX_AGE_MS; if (!options.refresh) { diff --git a/test/unit/focus-manifest-loader.test.ts b/test/unit/focus-manifest-loader.test.ts index b71054861a..810aaea17e 100644 --- a/test/unit/focus-manifest-loader.test.ts +++ b/test/unit/focus-manifest-loader.test.ts @@ -6,6 +6,7 @@ import { loadPublicRepoFocusManifest, loadRepoFocusManifest, loadRepoFocusManifests, + setLocalManifestReader, upsertRepoFocusManifest, REPO_FOCUS_MANIFEST_MAX_AGE_MS, REPO_FOCUS_MANIFEST_MAX_CONCURRENT_LOADS, @@ -331,3 +332,52 @@ describe("focus-manifest loader", () => { expect(manifest.warnings.join(" ")).toMatch(/mapping/i); }); }); + +describe("focus-manifest loader — container-private config (self-host)", () => { + afterEach(() => setLocalManifestReader(null)); + + it("prefers the registered local reader over the public fetcher and tags it api_record", async () => { + const env = createTestEnv(); + let fetched = 0; + setLocalManifestReader(async (repo) => (repo === "owner/private" ? "wantedPaths:\n - private/\n" : null)); + const manifest = await loadRepoFocusManifest(env, "owner/private", { + fetcher: async () => { + fetched += 1; + return JSON.stringify({ wantedPaths: ["public/"] }); + }, + }); + expect(manifest.source).toBe("api_record"); + expect(manifest.wantedPaths).toEqual(["private/"]); + expect(fetched).toBe(0); // the public `.gittensory.yml` was never fetched + }); + + it("falls through to the public fetcher when the local reader has no file for the repo", async () => { + const env = createTestEnv(); + let fetched = 0; + setLocalManifestReader(async () => null); + const manifest = await loadRepoFocusManifest(env, "owner/public", { + fetcher: async () => { + fetched += 1; + return JSON.stringify({ wantedPaths: ["src/"] }); + }, + }); + expect(manifest.source).toBe("repo_file"); + expect(manifest.wantedPaths).toEqual(["src/"]); + expect(fetched).toBe(1); + }); + + it("never consults the local reader on the publicOnly (contributor-preview) path", async () => { + const env = createTestEnv(); + let localCalls = 0; + setLocalManifestReader(async () => { + localCalls += 1; + return "wantedPaths:\n - private/\n"; + }); + const manifest = await loadPublicRepoFocusManifest(env, "owner/preview", { + fetcher: async () => JSON.stringify({ wantedPaths: ["src/"] }), + }); + expect(localCalls).toBe(0); // private config must never leak into a contributor-facing preview + expect(manifest.source).toBe("repo_file"); + expect(manifest.wantedPaths).toEqual(["src/"]); + }); +}); diff --git a/test/unit/private-config.test.ts b/test/unit/private-config.test.ts new file mode 100644 index 0000000000..93e56e26bb --- /dev/null +++ b/test/unit/private-config.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { localConfigCandidates, makeLocalManifestReader } from "../../src/selfhost/private-config"; + +describe("localConfigCandidates (container-private config filenames)", () => { + it("builds lowercased {owner}__{repo} candidates in .yml/.yaml/.json order", () => { + expect(localConfigCandidates("JSONbored/metagraphed")).toEqual(["jsonbored__metagraphed.yml", "jsonbored__metagraphed.yaml", "jsonbored__metagraphed.json"]); + }); + it("returns no candidates for an invalid repo full name", () => { + expect(localConfigCandidates("no-slash")).toEqual([]); // slash < 0 → slash <= 0 + expect(localConfigCandidates("/leading")).toEqual([]); // slash at 0 → slash <= 0 + expect(localConfigCandidates("trailing/")).toEqual([]); // slash at len-1 + }); +}); + +describe("makeLocalManifestReader (GITTENSORY_REPO_CONFIG_DIR)", () => { + it("returns null when the dir is unset or blank (⇒ public fetch)", () => { + expect(makeLocalManifestReader(undefined)).toBeNull(); // ?? right side + expect(makeLocalManifestReader("")).toBeNull(); + expect(makeLocalManifestReader(" ")).toBeNull(); // blank after trim + }); + + it("reads the first existing {owner}__{repo} file and returns its text", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + writeFileSync(join(dir, "jsonbored__metagraphed.yml"), "gate:\n enabled: false\n"); + const reader = makeLocalManifestReader(dir); + expect(reader).not.toBeNull(); + expect(await reader!("JSONbored/metagraphed")).toBe("gate:\n enabled: false\n"); + }); + + it("falls through .yml → .yaml → .json when earlier candidates are absent (read error → next)", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + writeFileSync(join(dir, "owner__repo.json"), '{"gate":{"enabled":true}}'); + const reader = makeLocalManifestReader(dir); + expect(await reader!("owner/repo")).toBe('{"gate":{"enabled":true}}'); + }); + + it("returns null when no private config file exists for the repo (⇒ loader uses the public file)", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + const reader = makeLocalManifestReader(dir); + expect(await reader!("owner/unconfigured")).toBeNull(); + }); + + it("returns null for an invalid repo full name (no candidates to try)", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + const reader = makeLocalManifestReader(dir); + expect(await reader!("no-slash")).toBeNull(); + }); +});