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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ LOOPOVER_REVIEW_DRAFT=false
# LOOPOVER_API_TOKEN= # server-to-server API bearer token
# GITTENSORY_API_TOKEN= # no longer read (removed by #4777)
# LOOPOVER_MCP_TOKEN= # shared MCP bearer token
# LOOPOVER_MCP_ADMIN_TOKEN= # separate, higher-privilege MCP admin token (config read/write
# # tools) -- distinct from LOOPOVER_MCP_TOKEN so a leaked ordinary
# # MCP credential can never rewrite fleet-wide gate policy. Inert
# # unless LOOPOVER_MCP_ADMIN_ENABLED is also set (see section 3).
# GITTENSORY_MCP_TOKEN= # no longer read (removed by #4777)
# INTERNAL_JOB_TOKEN=

Expand Down Expand Up @@ -246,6 +250,14 @@ LOOPOVER_REVIEW_DRAFT=false
# # silently falls back to built-in defaults). Set once you've
# # confirmed this is intentional, e.g. a fresh install with no
# # .loopover.yml written yet. Default false (warning shown).
# LOOPOVER_MCP_ADMIN_ENABLED= # registers the "admin" MCP tool category (read/write this instance's
# # own LOOPOVER_REPO_CONFIG_DIR config, list backups). Default OFF --
# # the tools are not even registered, matching the
# # LOOPOVER_REVIEW_SCREENSHOTS/_ENRICHMENT/_OPS "inert when off"
# # convention. Also requires LOOPOVER_MCP_ADMIN_TOKEN (section 2) and,
# # for the write tools specifically, flipping the config bind mount in
# # docker-compose.yml from :ro to :rw yourself -- see that file's
# # comment above the mount. See self-hosting-configuration.mdx.
# COMPOSE_PROJECT_NAME=loopover # Docker Compose's own project name; also labels the log stream
# # Promtail ships to Loki. Change it to run two stacks on one host
# # (#4896) -- Compose namespaces container names, named volumes, and
Expand Down
41 changes: 41 additions & 0 deletions apps/loopover-ui/content/docs/self-hosting-configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,47 @@ features:

The `features:` block above overrides a deployment-wide `LOOPOVER_REVIEW_*` flag (rag, reputation, safety) for this one repo, with three states per key: `true` forces the capability on for this repo (still subject to the env flag itself being enabled — it can never turn on a capability the operator has fully disabled at the deployment level); `false` forces it off for this repo regardless of the env flag; and omitting the key entirely falls back to the `LOOPOVER_REVIEW_REPOS` allowlist default, i.e. today's behavior for an operator who hasn't set anything here. See [Tuning your reviews](/docs/tuning) for the full `LOOPOVER_REVIEW_*` flag list this overrides.

## MCP admin config tools

By default the config above is edit-by-hand-and-restart only. Setting `LOOPOVER_MCP_ADMIN_ENABLED` additionally registers a small `admin` MCP tool category that reads and **writes** it: `loopover_admin_get_config`, `loopover_admin_write_config`, and `loopover_admin_list_config_backups`. Off by default, and three things have to be true before it does anything:

1. **`LOOPOVER_MCP_ADMIN_ENABLED=1`** — registers the tools at all. Unset (or falsy) means they don't exist on the server, not just "reject the call" — the same "inert when off" convention `LOOPOVER_REVIEW_SCREENSHOTS`/`LOOPOVER_REVIEW_ENRICHMENT`/`LOOPOVER_REVIEW_OPS` already use elsewhere in this app.
2. **A separate `LOOPOVER_MCP_ADMIN_TOKEN`.** Generate and set it exactly like any other bearer token below — but never reuse `LOOPOVER_MCP_TOKEN`'s value for it. They're deliberately different credentials so a leaked, end-user-obtainable `LOOPOVER_MCP_TOKEN` (see the callout above) can never rewrite fleet-wide gate policy; calls authenticated with the ordinary token are rejected even once the flag above is on.
3. **The config mount itself flipped to `:rw`.** `docker-compose.yml` mounts `LOOPOVER_REPO_CONFIG_DIR` read-only regardless of the flag above — enabling the flag alone does not make the mount secretly writable. Edit the line yourself:

<CodeBlock
filename=".env"
code={`LOOPOVER_MCP_ADMIN_ENABLED=1
LOOPOVER_MCP_ADMIN_TOKEN=<random-32-byte-token>`}
/>

<CodeBlock
filename="docker-compose.yml"
code={` volumes:
- ./loopover-config:/config:rw # was :ro`}
/>

Restart the `loopover` service after changing either (`docker compose up -d --no-deps loopover`, or `./scripts/selfhost-update.sh`). Both tokens support the same `_FILE` secret-file convention as `LOOPOVER_MCP_TOKEN` — see `secrets/README.md`.

`loopover_admin_write_config` validates against the same schema-aware validator `loopover_validate_config` already exposes (not a separate, looser check), writes a timestamped backup of whatever file it's about to overwrite first, and lands the new content via a temp-file-plus-rename atomic write. Pass `dryRun: true` to run that same validation and see what would happen without touching disk:

<CodeBlock
lang="json"
filename="example: loopover_admin_write_config"
code={`{
"scope": "repo",
"repoFullName": "your-org/your-repo",
"dryRun": true,
"content": "gate:\\n checkMode: visible\\n"
}`}
/>

Drop `dryRun` (or set it to `false`) to write for real once you're happy with the dry-run result. `scope` is `"global"` (the mount-root default file) or `"repo"` (pass `repoFullName`); `loopover_admin_get_config` additionally accepts `"effective"` to read the exact deep-merged view a real review sees, same as the "Private per-repo config" deep-merge described above. `loopover_admin_list_config_backups` takes the same `scope`/`repoFullName` pair and returns each backup's path and timestamp, newest first.

<Callout variant="warn" title="Boundaries">
These tools only read and write `LOOPOVER_REPO_CONFIG_DIR`. They do not trigger a redeploy, and they do not touch the public dashboard or `/v1/app/*` settings surface — `LOOPOVER_MCP_ADMIN_TOKEN` cannot sign into the control panel or call the routes `ADMIN_GITHUB_LOGINS` gates.
</Callout>

## Config-as-code blocks with no dashboard equivalent

Everything above has a dashboard row it mirrors. The fields below exist **only** in `.loopover.yml` — there is no DB column or dashboard toggle for them, so a self-host operator who never reads the example file may not know they exist.
Expand Down
4 changes: 2 additions & 2 deletions apps/loopover-ui/content/docs/self-hosting-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ eyebrow: Self-hosting
]}
/>

`docker-compose.yml` ships native Docker Compose `secrets:` mounts for the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/internal-job tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at `/run/secrets/<name>`, never exposed via `docker inspect` or `docker compose config` the way a plain `environment:`/`env_file` value is. This is purely additive: an inline `.env` value always takes priority if you set both, so you can migrate one secret at a time, or not at all. See `secrets/README.md` for the full file list.
`docker-compose.yml` ships native Docker Compose `secrets:` mounts for the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/MCP-admin/internal-job tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at `/run/secrets/<name>`, never exposed via `docker inspect` or `docker compose config` the way a plain `environment:`/`env_file` value is. This is purely additive: an inline `.env` value always takes priority if you set both, so you can migrate one secret at a time, or not at all. See `secrets/README.md` for the full file list.

<CodeBlock
filename="shell"
Expand All @@ -50,7 +50,7 @@ category a secret falls into before rotating it.
{
title: "Freely rotatable",
description:
"GITHUB_WEBHOOK_SECRET, LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, INTERNAL_JOB_TOKEN, SELFHOST_SETUP_TOKEN, REES_SHARED_SECRET. Static bearer comparisons -- nothing is encrypted with them. Generate a new value, restart both sides. Only cost: updating callers holding the old value.",
"GITHUB_WEBHOOK_SECRET, LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_MCP_ADMIN_TOKEN, INTERNAL_JOB_TOKEN, SELFHOST_SETUP_TOKEN, REES_SHARED_SECRET. Static bearer comparisons -- nothing is encrypted with them. Generate a new value, restart both sides. Only cost: updating callers holding the old value.",
},
{
title: "Externally issued",
Expand Down
14 changes: 13 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ services:
GITHUB_WEBHOOK_SECRET_FILE: "${GITHUB_WEBHOOK_SECRET_FILE:-/run/secrets/github_webhook_secret}"
LOOPOVER_API_TOKEN_FILE: "${LOOPOVER_API_TOKEN_FILE:-/run/secrets/loopover_api_token}"
LOOPOVER_MCP_TOKEN_FILE: "${LOOPOVER_MCP_TOKEN_FILE:-/run/secrets/loopover_mcp_token}"
# Admin MCP surface (#7721): OFF by default (tools aren't even registered) and requires BOTH this
# flag AND its own higher-privilege token (never LOOPOVER_MCP_TOKEN) -- a leaked ordinary MCP
# credential can never rewrite fleet-wide gate policy through it. Writing through it also requires
# flipping the config mount below (under `volumes:`) from :ro to :rw yourself -- see its own comment.
LOOPOVER_MCP_ADMIN_ENABLED: "${LOOPOVER_MCP_ADMIN_ENABLED:-}"
LOOPOVER_MCP_ADMIN_TOKEN_FILE: "${LOOPOVER_MCP_ADMIN_TOKEN_FILE:-/run/secrets/loopover_mcp_admin_token}"
INTERNAL_JOB_TOKEN_FILE: "${INTERNAL_JOB_TOKEN_FILE:-/run/secrets/internal_job_token}"
SELFHOST_SETUP_TOKEN_FILE: "${SELFHOST_SETUP_TOKEN_FILE:-/run/secrets/selfhost_setup_token}"
TOKEN_ENCRYPTION_SECRET_FILE: "${TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/token_encryption_secret}"
Expand All @@ -153,7 +159,10 @@ services:
# Container-private per-repo config dir (read-only). Create ./loopover-config/{owner}__{repo}/.loopover.yml
# locally, optionally alongside a ./loopover-config/.loopover.yml global default that every per-repo file
# is deep-merged over (gitignored — never commit real policy; see config/examples/ for generic templates).
# Absent ⇒ Docker mounts an empty dir ⇒ defaults apply.
# Absent ⇒ Docker mounts an empty dir ⇒ defaults apply. Stays :ro even with LOOPOVER_MCP_ADMIN_ENABLED
# set (#7721) -- the admin MCP write tools need this changed to :rw yourself before they can write
# anything; the app flag alone never makes this mount secretly writable. Flip it locally:
# - ./loopover-config:/config:rw
- ./loopover-config:/config:ro
# Mounted read-only at /run/secrets/<name> (Compose's default target). See the top-level `secrets:`
# block above and secrets/README.md.
Expand All @@ -172,6 +181,7 @@ services:
- github_webhook_secret
- loopover_api_token
- loopover_mcp_token
- loopover_mcp_admin_token
- internal_job_token
- selfhost_setup_token
- token_encryption_secret
Expand Down Expand Up @@ -1327,6 +1337,8 @@ secrets:
file: ./secrets/loopover_api_token.txt
loopover_mcp_token:
file: ./secrets/loopover_mcp_token.txt
loopover_mcp_admin_token:
file: ./secrets/loopover_mcp_admin_token.txt
internal_job_token:
file: ./secrets/internal_job_token.txt
selfhost_setup_token:
Expand Down
1 change: 1 addition & 0 deletions scripts/selfhost-init-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ RANDOM_SECRET_FILES=(
"github_webhook_secret.txt"
"loopover_api_token.txt"
"loopover_mcp_token.txt"
"loopover_mcp_admin_token.txt"
"internal_job_token.txt"
"selfhost_setup_token.txt"
"token_encryption_secret.txt"
Expand Down
3 changes: 2 additions & 1 deletion secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ see the tradeoff explained above for why `600` breaks the app's own ability to r
| `github_webhook_secret.txt` | `GITHUB_WEBHOOK_SECRET_FILE` | HMAC key GitHub webhook deliveries are verified against. |
| `loopover_api_token.txt` | `LOOPOVER_API_TOKEN_FILE` | Server-to-server API bearer token. |
| `loopover_mcp_token.txt` | `LOOPOVER_MCP_TOKEN_FILE` | Shared MCP bearer token. |
| `loopover_mcp_admin_token.txt` | `LOOPOVER_MCP_ADMIN_TOKEN_FILE` | Higher-privilege MCP admin token (config read/write tools); inert unless `LOOPOVER_MCP_ADMIN_ENABLED` is also set. |
| `internal_job_token.txt` | `INTERNAL_JOB_TOKEN_FILE` | Gates internal-only routes (e.g. `/v1/internal/*`). |
| `selfhost_setup_token.txt` | `SELFHOST_SETUP_TOKEN_FILE` | Unlocks the first-run `/setup` wizard. |
| `token_encryption_secret.txt` | `TOKEN_ENCRYPTION_SECRET_FILE` | AES-256-GCM master secret for maintainer BYOK keys at rest. |
Expand All @@ -80,7 +81,7 @@ of those too; add a matching `secrets:` entry in `docker-compose.yml` (or a
## Never commit real files here

Everything in this directory except this README is gitignored. `scripts/selfhost-init-secrets.sh`
generates a real random value for the seven self-generatable files (so `docker compose build`/`up`
generates a real random value for each self-generatable file (so `docker compose build`/`up`
never fails on a missing file, and boots without any manual `openssl` step) and creates only an
**empty** placeholder for the four externally-issued ones it can't generate a usable value for. Either
way, it only ever touches the *permissions* of a file that is still empty, never its content — the
Expand Down
5 changes: 4 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6783,7 +6783,10 @@ async function requireAppRole(c: ProtectedRouteContext, allowedRoles: ControlPan
if (!identity) return c.json({ error: "unauthorized" }, 401);
if (identity.kind !== "session") {
// LOOPOVER_MCP_TOKEN is a shared end-user credential; it must not satisfy app-role gates implicitly.
if (identity.actor === "mcp") return c.json({ error: "insufficient_role" }, 403);
// LOOPOVER_MCP_ADMIN_TOKEN (#7721) is narrower still by design -- config read/write only, explicitly
// NOT the public dashboard/API settings surface these app-role gates protect -- so it's excluded here
// too, same as the ordinary mcp token.
if (identity.actor === "mcp" || identity.actor === "mcp-admin") return c.json({ error: "insufficient_role" }, 403);
return null;
}
const summary = await loadControlPanelRoleSummary(c.env, identity.actor);
Expand Down
7 changes: 6 additions & 1 deletion src/auth/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function nonBlank(value: string | undefined): string | undefined {
}

export type AuthIdentity =
| { kind: "static"; actor: "api" | "mcp" | "internal" }
| { kind: "static"; actor: "api" | "mcp" | "mcp-admin" | "internal" }
| { kind: "session"; actor: string; session: AuthSessionRecord };

export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
Expand Down Expand Up @@ -109,6 +109,11 @@ export function createOpaqueToken(prefix = "gts"): string {
export async function authenticatePrivateToken(env: Env, token: string | undefined): Promise<AuthIdentity | null> {
if (!token) return null;
if (await timingSafeEqual(token, nonBlank(env.LOOPOVER_API_TOKEN))) return { kind: "static", actor: "api" };
// Checked before the general LOOPOVER_MCP_TOKEN: a distinct, higher-privilege credential (#7721) so a leaked
// ordinary MCP token can never reach the admin config-write tools, which gate on actor === "mcp-admin"
// specifically. Order doesn't change behavior here (the two secrets are never equal in a real deployment),
// but checking the more-privileged token first keeps this function reading top-to-bottom by privilege.
if (await timingSafeEqual(token, nonBlank(env.LOOPOVER_MCP_ADMIN_TOKEN))) return { kind: "static", actor: "mcp-admin" };
if (await timingSafeEqual(token, nonBlank(env.LOOPOVER_MCP_TOKEN))) return { kind: "static", actor: "mcp" };
return authenticateSessionToken(env, token);
}
Expand Down
12 changes: 12 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,18 @@ declare global {
LOOPOVER_API_TOKEN?: string;
/** Shared MCP bearer token (src/auth/security.ts). */
LOOPOVER_MCP_TOKEN?: string;
/** Higher-privilege MCP bearer token (#7721), distinct from LOOPOVER_MCP_TOKEN so a leaked ordinary MCP
* credential can never reach the admin config-write tools -- checked first in authenticatePrivateToken,
* resolves to actor "mcp-admin". Self-host only in practice: the admin tools it unlocks operate on
* LOOPOVER_REPO_CONFIG_DIR, which is unset (and thus a no-op) on the hosted Cloudflare deployment. */
LOOPOVER_MCP_ADMIN_TOKEN?: string;
/** Master opt-in (#7721, default OFF) for the "admin" MCP tool category (read/write a self-hosted instance's
* own private .loopover.yml config). Gates TOOL REGISTRATION itself, not just call-time authorization --
* matching this repo's "truly inert when off, tool not even registered" convention -- so the surface is
* invisible in tools/list unless an operator explicitly turns it on. Each tool call still separately
* requires actor === "mcp-admin" (LOOPOVER_MCP_ADMIN_TOKEN), so enabling this flag alone grants nothing to
* a caller using the ordinary LOOPOVER_MCP_TOKEN. */
LOOPOVER_MCP_ADMIN_ENABLED?: string;
INTERNAL_JOB_TOKEN: string;
/** Repos the shared LOOPOVER_MCP_TOKEN may propose/decide/manage actions on (comma/whitespace `owner/repo`
* list, or `*`/`all` for every repo). Unset ⇒ none — LOOPOVER_MCP_TOKEN is a shared, end-user-obtainable
Expand Down
Loading
Loading