Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,10 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# ORB_COLLECTOR_URL=https://api.loopover.ai/v1/orb/ingest # loopover's hosted collector (default; override for your own)
# ORB_COLLECTOR_TOKEN= # bearer credential for a private/self-hosted collector (ORB_COLLECTOR_URL
# # above). Unset when using loopover's own hosted collector.
# ORB_COLLECTOR_INSTANCE_SECRET= # per-instance credential returned ONCE by an operator's
# # POST /v1/internal/orb/instances/register call (#9121).
# # Required for the published risk-control guarantee to be
# # accepted from this instance; unset if not opted into that.
#
# Token broker (optional): get GitHub tokens from the central Orb (you installed the Orb App) instead of running
# your own GitHub App. Set the enrollment secret the operator issued for your install; unset = use your own App key.
Expand Down
5 changes: 5 additions & 0 deletions apps/loopover-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "ORB_BROKER_URL",
firstReference: "src/server.ts",
},
{
name: "ORB_COLLECTOR_INSTANCE_SECRET",
firstReference: "src/selfhost/orb-collector.ts",
},
{
name: "ORB_COLLECTOR_TOKEN",
firstReference: "src/selfhost/orb-collector.ts",
Expand Down Expand Up @@ -741,6 +745,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `ORB_ANONYMIZE` | `src/selfhost/orb-collector.ts` |",
"| `ORB_APP_ID` | `src/selfhost/orb-collector.ts` |",
"| `ORB_BROKER_URL` | `src/server.ts` |",
"| `ORB_COLLECTOR_INSTANCE_SECRET` | `src/selfhost/orb-collector.ts` |",
"| `ORB_COLLECTOR_TOKEN` | `src/selfhost/orb-collector.ts` |",
"| `ORB_COLLECTOR_URL` | `src/selfhost/orb-collector.ts` |",
"| `ORB_ENROLLMENT_SECRET` | `src/selfhost/orb-collector.ts` |",
Expand Down
20 changes: 20 additions & 0 deletions migrations/0187_orb_instance_credentials.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- #9121: the "registered instance" trust gate on the published risk-control guarantee authenticated
-- nothing -- instance_id was a plain body-supplied field, checked only against the shared FLEET-WIDE
-- ORB_INGEST_TOKEN every exporter holds. Any holder of that token could present ANY registered instance's
-- id and write (or, via an absent arm, DELETE) that instance's published guarantee. This adds a per-instance
-- credential the collector mints at registration time (returned once, in plaintext, then only its hash is
-- kept) and requires it for every risk-control write. Ordinary outcome/health ingest is UNCHANGED -- it
-- stays open by design (see 0061's own comment); only the risk-control write path now checks this.
ALTER TABLE orb_instances ADD COLUMN ingest_secret_hash TEXT;

-- Per-instance risk-control arms, replacing the two global `system_flags` cells
-- (`riskcontrol:fleet:close` / `riskcontrol:fleet:merge`) that let ANY registered instance overwrite the
-- SAME fleet-wide row. Scoped per instance so one compromised or miscalibrated peer can only ever affect
-- its own row; the public read (loadFleetGuarantee) aggregates across registered instances at query time.
CREATE TABLE IF NOT EXISTS orb_risk_control_arms (
instance_id TEXT NOT NULL,
arm TEXT NOT NULL,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (instance_id, arm)
);
8 changes: 8 additions & 0 deletions migrations/0188_pull_request_issue_author_github_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- #9125: every contributor-scoped anti-abuse control (the blacklist, open-item caps, moderation tally,
-- review-nag ping counter) keyed on the mutable GitHub login -- the immutable numeric user id was captured
-- on other paths (auth/security.ts, orb/oauth.ts) but discarded at the webhook content-ingest boundary. A
-- banned contributor could clear every one of these controls at once by renaming their account. These
-- columns let the ingest upsert persist the id alongside the login so identity-keyed controls can match on
-- id-when-present, surviving a rename.
ALTER TABLE pull_requests ADD COLUMN author_github_id INTEGER;
ALTER TABLE issues ADD COLUMN author_github_id INTEGER;
18 changes: 18 additions & 0 deletions migrations/0189_submitter_outcome_log.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- #9131: submitter_stats.submissions counted webhook PASSES, not submissions -- recordSubmissionOutcome had
-- no per-PR idempotency key, so re-gating the SAME PR (a body edit, a push, or a third party's review
-- comment on a rival's held PR) incremented the counter again every time. This log is the idempotency key:
-- one row per (project, submitter, pull_number, outcome) ever actually counted. recordSubmissionOutcome
-- INSERT OR IGNOREs here first and only bumps submitter_stats when the insert actually created a new row --
-- so N re-gates of one PR yield exactly one recorded outcome, and a still-open "manual" hold is recorded (at
-- most) once per PR rather than once per re-gate. recorded_at also gives the burst-detection signal a real
-- WINDOW to decay against, instead of reading the all-time submitter_stats aggregate forever.
CREATE TABLE IF NOT EXISTS submitter_outcome_log (
project TEXT NOT NULL,
submitter TEXT NOT NULL,
pull_number INTEGER NOT NULL,
outcome TEXT NOT NULL,
recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (project, submitter, pull_number, outcome)
);

CREATE INDEX IF NOT EXISTS submitter_outcome_log_window_idx ON submitter_outcome_log(project, submitter, recorded_at);
37 changes: 28 additions & 9 deletions packages/loopover-engine/src/settings/contributor-blacklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export function normalizeContributorBlacklist(input: unknown): { entries: Contri
if (seen.has(key)) continue; // first occurrence wins
seen.add(key);
const entry: ContributorBlacklistEntry = { login };
// #9125: an optional immutable id, rename-proofing this entry. Dropped (not widened to 0/negative) if
// malformed, same discipline as every other field here.
if (typeof record.githubId === "number" && Number.isInteger(record.githubId) && record.githubId > 0) entry.githubId = record.githubId;
if (typeof record.reason === "string" && record.reason.trim().length > 0) entry.reason = record.reason.trim().slice(0, MAX_REASON_CHARS);
if (Array.isArray(record.evidence)) {
const evidence = record.evidence.filter((ref): ref is string => typeof ref === "string" && ref.trim().length > 0).map((ref) => ref.trim().slice(0, MAX_EVIDENCE_CHARS)).slice(0, MAX_EVIDENCE);
Expand All @@ -62,17 +65,33 @@ export function normalizeContributorBlacklist(input: unknown): { entries: Contri
return { entries, warnings };
}

/** The blacklist entry matching `login` (case-insensitive), or null. Tolerates an absent list (treated as empty)
* so callers can pass the optional `settings.contributorBlacklist` directly. */
export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): ContributorBlacklistEntry | null {
if (!login) return null;
const key = login.toLowerCase();
return (entries ?? []).find((entry) => entry.login.toLowerCase() === key) ?? null;
/**
* The blacklist entry matching `login` OR `githubId` (case-insensitive login; exact id), or null. Tolerates
* an absent list (treated as empty) so callers can pass the optional `settings.contributorBlacklist`
* directly.
*
* #9125: matches id-WHEN-PRESENT union login, so a banned contributor cannot clear the block by renaming --
* GitHub carries the account (and its immutable id) across a rename, so an entry that has captured the id
* still matches under the new login even though `entry.login` itself is now stale. `githubId` is optional
* on BOTH sides (the entry and the call): omit it and this behaves exactly as the login-only match always
* did, so existing entries and callers that haven't threaded an id through yet keep working unchanged.
*/
export function findBlacklistEntry(
login: string | null | undefined,
entries: ContributorBlacklistEntry[] | undefined,
githubId?: number | null | undefined,
): ContributorBlacklistEntry | null {
const key = login ? login.toLowerCase() : null;
return (
(entries ?? []).find(
(entry) => (typeof githubId === "number" && entry.githubId === githubId) || (key !== null && entry.login.toLowerCase() === key),
) ?? null
);
}

/** True iff `login` is on the resolved blacklist. */
export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): boolean {
return findBlacklistEntry(login, entries) !== null;
/** True iff `login` (or `githubId`) is on the resolved blacklist. */
export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined, githubId?: number | null | undefined): boolean {
return findBlacklistEntry(login, entries, githubId) !== null;
}

/** Union multiple blacklist sources (e.g. the shared/global list + the per-repo list) by case-insensitive login.
Expand Down
4 changes: 4 additions & 0 deletions packages/loopover-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export type AdvisoryAiRoutingConfig = {

export type ContributorBlacklistEntry = {
login: string;
/** #9125: the login's IMMUTABLE numeric GitHub user id, when the operator has it (e.g. copied from an
* audit event or the API). Optional so existing login-only entries keep working; when present, matching
* is id-when-present UNION login, so a renamed-then-back account still can't shed a ban by renaming. */
githubId?: number | undefined;
/** Why the account is blocked. Free-text maintainer metadata; not published in automated close comments. */
reason?: string | undefined;
/** PR/issue URLs (or other maintainer refs) evidencing the block. */
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-schema-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const RAW_SQL_ONLY_TABLES: Set<string> = new Set([
"orb_pr_outcomes",
"orb_relay_failures",
"orb_reuse_counters",
"orb_risk_control_arms",
"orb_signals",
"orb_webhook_events",
"override_audit",
Expand All @@ -66,6 +67,7 @@ export const RAW_SQL_ONLY_TABLES: Set<string> = new Set([
"review_targets",
"submission_drafts",
"submission_user_tokens",
"submitter_outcome_log",
"submitter_stats",
"system_flags",
"tunables_overrides",
Expand Down
Loading
Loading