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
13 changes: 13 additions & 0 deletions .loopover-ams.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,16 @@ maxIterations: 3
# Per-iteration turn budget passed to the coding-agent driver.
# A non-integer is floored. Default: 6.
maxTurnsPerIteration: 6

# Network-egress allowlist additions (#7857) for the sandboxed coding-agent
# execution environment, on top of the always-on defaults (OS package
# registries, GitHub -- the target repo's own git remote). Enforced deny-by-
# default: an attempt can reach exactly these hosts plus the always-on
# defaults, nothing else. `ecosystems` picks whole registry categories (their
# real hostnames are resolved internally, e.g. `npm` -> registry.npmjs.org);
# `extraHosts` allows specific additional hostnames (e.g. a project's own
# third-party API). Values for ecosystems: npm | pypi | crates | go |
# rubygems | packagist | maven | nuget. Default: { ecosystems: [], extraHosts: [] }.
networkAllowlist:
ecosystems: []
extraHosts: []
28 changes: 26 additions & 2 deletions apps/loopover-ui/src/lib/ams-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [
firstReference: "lib/deny-hook-synthesis.ts",
defaultValue: "",
},
{
name: "LOOPOVER_MINER_DISABLE_EGRESS_FIREWALL",
firstReference: "lib/generate-egress-firewall-config.ts",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_DISCOVERY_INDEX_URL",
firstReference: "lib/discovery-index-client.ts",
Expand Down Expand Up @@ -106,6 +111,21 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [
firstReference: "lib/logger.ts",
defaultValue: "",
},
{
name: "LOOPOVER_MINER_NEON_API_KEY",
firstReference: "lib/egress-allowlist.ts",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_NEON_PARENT_BRANCH_ID",
firstReference: "lib/egress-allowlist.ts",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_NEON_PROJECT_ID",
firstReference: "lib/egress-allowlist.ts",
defaultValue: null,
},
{
name: "LOOPOVER_MINER_NO_UPDATE_CHECK",
firstReference: "lib/update-check.ts",
Expand Down Expand Up @@ -158,7 +178,7 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [
},
{
name: "LOOPOVER_MINER_SENTRY_DSN",
firstReference: "lib/sentry.ts",
firstReference: "lib/egress-allowlist.ts",
defaultValue: null,
},
{
Expand Down Expand Up @@ -227,6 +247,7 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [
'| `LOOPOVER_MINER_CONTROL_PLANE_ADMIN_TOKEN` | `lib/tenant-client.ts` | `""` |',
'| `LOOPOVER_MINER_CONTROL_PLANE_URL` | `lib/tenant-client.ts` | `""` |',
'| `LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB` | `lib/deny-hook-synthesis.ts` | `""` |',
"| `LOOPOVER_MINER_DISABLE_EGRESS_FIREWALL` | `lib/generate-egress-firewall-config.ts` | (none) |",
'| `LOOPOVER_MINER_DISCOVERY_INDEX_URL` | `lib/discovery-index-client.ts` | `""` |',
'| `LOOPOVER_MINER_DISCOVERY_PLANE` | `lib/discovery-index-client.ts` | `""` |',
'| `LOOPOVER_MINER_DISCOVERY_SHARED_SECRET` | `lib/discovery-index-client.ts` | `""` |',
Expand All @@ -235,6 +256,9 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [
"| `LOOPOVER_MINER_GOVERNOR_LEDGER_DB` | `lib/governor-ledger.ts` | (none) |",
"| `LOOPOVER_MINER_GOVERNOR_STATE_DB` | `lib/governor-state.ts` | (none) |",
'| `LOOPOVER_MINER_LOG_LEVEL` | `lib/logger.ts` | `""` |',
"| `LOOPOVER_MINER_NEON_API_KEY` | `lib/egress-allowlist.ts` | (none) |",
"| `LOOPOVER_MINER_NEON_PARENT_BRANCH_ID` | `lib/egress-allowlist.ts` | (none) |",
"| `LOOPOVER_MINER_NEON_PROJECT_ID` | `lib/egress-allowlist.ts` | (none) |",
'| `LOOPOVER_MINER_NO_UPDATE_CHECK` | `lib/update-check.ts` | `""` |',
'| `LOOPOVER_MINER_ORB_EXPORT_DB` | `lib/orb-export.ts` | `""` |',
"| `LOOPOVER_MINER_PLAN_STORE_DB` | `lib/plan-store.ts` | (none) |",
Expand All @@ -245,7 +269,7 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [
"| `LOOPOVER_MINER_RANKED_CANDIDATES_DB` | `lib/ranked-candidates.ts` | (none) |",
"| `LOOPOVER_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.ts` | (none) |",
"| `LOOPOVER_MINER_RUN_STATE_DB` | `lib/run-state.ts` | (none) |",
"| `LOOPOVER_MINER_SENTRY_DSN` | `lib/sentry.ts` | (none) |",
"| `LOOPOVER_MINER_SENTRY_DSN` | `lib/egress-allowlist.ts` | (none) |",
'| `LOOPOVER_MINER_SENTRY_ENVIRONMENT` | `lib/sentry.ts` | `"production"` |',
'| `LOOPOVER_MINER_VERSION` | `lib/version.ts` | `""` |',
"| `LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB` | `lib/worktree-allocator.ts` | (none) |",
Expand Down
4 changes: 4 additions & 0 deletions k8s/miner-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ spec:
app.kubernetes.io/name: loopover-miner
app.kubernetes.io/component: fleet-worker
spec:
# #7857's network-egress firewall (deny-by-default, ratified allowlist) is NOT enforced on this
# deployment path yet -- `runAsNonRoot: true` below is a hard, kubelet-enforced constraint that rules out
# the root-then-drop entrypoint the Docker/Compose path uses; a k8s-native initContainer-based equivalent
# is tracked in #8282. Do not weaken runAsNonRoot to work around this -- follow #8282 instead.
securityContext:
runAsNonRoot: true
runAsUser: 1000
Expand Down
14 changes: 14 additions & 0 deletions packages/loopover-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ docker compose -f docker-compose.miner.yml up -d --build

**Scaling to N parallel workers.** `docker compose -f docker-compose.miner.yml up -d --scale miner=N` gives every replica the **same** `miner-data` volume — and the miner's SQLite ledgers are **not** safe for concurrent access, so N replicas on one volume will contend/corrupt. To run N **isolated** workers, give each its own state: run N separate compose projects (`docker compose -p miner-1 …`, `-p miner-2 …` — `-p` namespaces the volume) or point each at a distinct `LOOPOVER_MINER_CONFIG_DIR` on its own mount. For built-in isolated horizontal scaling, use the Kubernetes StatefulSet in [`k8s/`](../../k8s/) (per-pod volumes).

### Network-egress firewall (Docker Compose / `docker run`)

The container enforces deny-by-default network egress for the coding-agent execution environment (#7648, #7857): outbound traffic is blocked except to a curated allowlist (OS package registries, GitHub — the target repo's own git remote) plus whatever an operator declares in [`.loopover-ams.yml`](../../.loopover-ams.yml.example)'s `networkAllowlist` (ecosystem registries like npm/PyPI, or specific extra hostnames). This applies to the **whole container**, not just the coding-agent subprocess — no per-process network isolation exists yet, so the miner's own necessary calls (the Orb broker, Sentry, the discovery-index plane, Neon) are allowed automatically whenever their corresponding feature is actually configured.

This changes what the container needs at `docker run`/Compose time:

- `docker-compose.miner.yml` already grants the required `NET_ADMIN`/`NET_RAW` capabilities — nothing to change if you use it as-is.
- Running with a bare `docker run` instead: add `--cap-add=NET_ADMIN --cap-add=NET_RAW`.
- The image now starts as **root** (not `node`) so its entrypoint can configure the firewall, then drops to the unprivileged `node` user before running anything else — the actual miner process runs exactly as before.

If the firewall setup itself fails (a misconfigured environment, `dnsmasq`/`iptables` erroring), the container **fails to start** rather than silently running with no restriction — that's deliberate (a coding-agent sandbox with no network policy at all is worse than not running). If you hit a real snag, `LOOPOVER_MINER_DISABLE_EGRESS_FIREWALL=1` is the documented escape hatch to get unblocked, then use `.loopover-ams.yml`'s `networkAllowlist` to fix it properly and remove the override.

Only covers Docker/Compose today. Kubernetes (`k8s/miner-deployment.yaml`) and systemd (bare-host) need their own privilege-setup mechanism (a k8s `initContainer`, a systemd `ExecStartPre=+`) — tracked separately in [#8282](https://github.com/JSONbored/loopover/issues/8282), not yet enforced on those paths.

### Running fleet mode alongside ORB's `ams-observability` profile

Fleet mode keeps miner state in a named `miner-data` volume, but ORB's `ams-reporting-exporter` (root [`docker-compose.yml`](../../docker-compose.yml), `--profile ams-observability`) reads the miner's ledgers from a **host** directory (default `~/.config/loopover-miner`). A named volume's host path is a Docker-managed internal detail, so the two never line up on their own — the exporter reads an empty directory and the Grafana AMS datasources stay **silently empty**.
Expand Down
37 changes: 31 additions & 6 deletions packages/loopover-miner/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,39 @@ ENV NODE_ENV=production \
LOOPOVER_MINER_CONFIG_DIR=/data/miner \
LOOPOVER_MINER_VERSION=${LOOPOVER_VERSION} \
PATH=/app/node_modules/.bin:$PATH
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/packages/loopover-miner ./packages/loopover-miner
COPY --from=build --chown=node:node /app/packages/loopover-engine ./packages/loopover-engine
RUN mkdir -p /data/miner && chown -R node:node /data
USER node
# #7857: dnsmasq/iptables/ipset implement the network-egress firewall (deny-by-default, allowlisted via the
# operator's own .loopover-ams.yml); gosu drops from root to the node user after that firewall is set up --
# see egress-firewall-entrypoint.sh for the full mechanism and why this image now starts as root.
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq iptables ipset gosu \
&& rm -rf /var/lib/apt/lists/*
# #7857 security review (round 2): root:root on the FULL code tree, not just the two files that directly
# EXECUTE with root privilege. Owning only the entrypoint + generate-egress-firewall-config.{js,ts} left every
# module they import (ams-policy.js, egress-*.js, @loopover/engine, ...) node:node-owned -- a compromised
# node-level process (the coding-agent subprocess this whole mechanism sandboxes) could tamper with any of
# those, and root would load + execute the tampered code on the container's next restart. `--chown` on COPY
# only changes ownership, not mode bits, so this stays world-readable (standard 644/755 from the build stage)
# -- just no longer node-writable. The miner's own real runtime writes (config/.env, ledgers, the coding
# agent's working directory) all resolve under LOOPOVER_MINER_CONFIG_DIR (/data/miner, below) or a target-repo
# checkout, never /app, so this closes the persistence vector without touching anything node actually needs
# to write.
COPY --from=build --chown=root:root /app/node_modules ./node_modules
COPY --from=build --chown=root:root /app/packages/loopover-miner ./packages/loopover-miner
COPY --from=build --chown=root:root /app/packages/loopover-engine ./packages/loopover-engine
RUN mkdir -p /data/miner && chown -R node:node /data \
Comment thread
JSONbored marked this conversation as resolved.
# Not executable in git (checked in as a normal 644 file) -- COPY --chown above only sets ownership, so
# the exec bit still needs setting explicitly, same as before this file became root:root by default.
&& chmod 755 /app/packages/loopover-miner/scripts/egress-firewall-entrypoint.sh
VOLUME ["/data/miner"]
# No HEALTHCHECK: the miner is a batch/CLI workload (`docker run … loopover-miner <cmd>`), not a
# long-running HTTP service — there is no steady-state endpoint to probe unless an operator wraps
# the container in their own supervising loop.
ENTRYPOINT ["loopover-miner"]
#
# #7857: this image now starts as ROOT (no `USER node` here) so the entrypoint can set up the egress firewall,
# which needs root/NET_ADMIN to configure iptables/ipset -- but it never RUNS the miner itself as root. The
# entrypoint's own last action is `exec gosu node loopover-miner ...`, which both drops privileges AND replaces
# the entrypoint process (`exec`, not a child) -- the actual miner command runs as `node`, exactly as it always
# has, for the entire remainder of the container's life. `docker-compose.miner.yml` grants the NET_ADMIN/NET_RAW
# capabilities this setup step needs (see that file's own comment); `LOOPOVER_MINER_DISABLE_EGRESS_FIREWALL` is
# the documented opt-out for an operator who hits a real snag (see the entrypoint script's own header).
ENTRYPOINT ["/app/packages/loopover-miner/scripts/egress-firewall-entrypoint.sh"]
CMD ["doctor"]
11 changes: 10 additions & 1 deletion packages/loopover-miner/docker-compose.miner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,18 @@ services:
context: ../..
dockerfile: packages/loopover-miner/Dockerfile
image: loopover-miner:latest
# ENTRYPOINT is `loopover-miner`; `run` is the continuous fleet-worker loop.
# ENTRYPOINT is egress-firewall-entrypoint.sh (#7857), which sets up the network-egress firewall as root
# then execs `loopover-miner` as the unprivileged `node` user; `run` is the continuous fleet-worker loop.
command: ["run"]
restart: unless-stopped
# #7857: NET_ADMIN/NET_RAW so the entrypoint's iptables/ipset egress-firewall setup can actually configure
# rules -- same capability class this repo's own tailscale service already grants (docker-compose.yml), just
# container-scoped here rather than host-networked. The container itself still runs the real miner command
# as the unprivileged `node` user throughout (see the Dockerfile's own comment) -- these capabilities are
# only ever exercised by the root-owned entrypoint's brief setup window before it drops privileges.
cap_add:
- NET_ADMIN
- NET_RAW
# Operator-supplied secrets/config (GITHUB_TOKEN, optional provider keys). Copy the .example, fill it in,
# and NEVER commit the real file — env vars stay out of the compose file and out of `docker inspect` args.
env_file:
Expand Down
6 changes: 5 additions & 1 deletion packages/loopover-miner/docs/env-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
| `LOOPOVER_MINER_CONTROL_PLANE_ADMIN_TOKEN` | `lib/tenant-client.ts` | `""` |
| `LOOPOVER_MINER_CONTROL_PLANE_URL` | `lib/tenant-client.ts` | `""` |
| `LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB` | `lib/deny-hook-synthesis.ts` | `""` |
| `LOOPOVER_MINER_DISABLE_EGRESS_FIREWALL` | `lib/generate-egress-firewall-config.ts` | (none) |
| `LOOPOVER_MINER_DISCOVERY_INDEX_URL` | `lib/discovery-index-client.ts` | `""` |
| `LOOPOVER_MINER_DISCOVERY_PLANE` | `lib/discovery-index-client.ts` | `""` |
| `LOOPOVER_MINER_DISCOVERY_SHARED_SECRET` | `lib/discovery-index-client.ts` | `""` |
Expand All @@ -24,6 +25,9 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
| `LOOPOVER_MINER_GOVERNOR_LEDGER_DB` | `lib/governor-ledger.ts` | (none) |
| `LOOPOVER_MINER_GOVERNOR_STATE_DB` | `lib/governor-state.ts` | (none) |
| `LOOPOVER_MINER_LOG_LEVEL` | `lib/logger.ts` | `""` |
| `LOOPOVER_MINER_NEON_API_KEY` | `lib/egress-allowlist.ts` | (none) |
| `LOOPOVER_MINER_NEON_PARENT_BRANCH_ID` | `lib/egress-allowlist.ts` | (none) |
| `LOOPOVER_MINER_NEON_PROJECT_ID` | `lib/egress-allowlist.ts` | (none) |
| `LOOPOVER_MINER_NO_UPDATE_CHECK` | `lib/update-check.ts` | `""` |
| `LOOPOVER_MINER_ORB_EXPORT_DB` | `lib/orb-export.ts` | `""` |
| `LOOPOVER_MINER_PLAN_STORE_DB` | `lib/plan-store.ts` | (none) |
Expand All @@ -34,7 +38,7 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
| `LOOPOVER_MINER_RANKED_CANDIDATES_DB` | `lib/ranked-candidates.ts` | (none) |
| `LOOPOVER_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.ts` | (none) |
| `LOOPOVER_MINER_RUN_STATE_DB` | `lib/run-state.ts` | (none) |
| `LOOPOVER_MINER_SENTRY_DSN` | `lib/sentry.ts` | (none) |
| `LOOPOVER_MINER_SENTRY_DSN` | `lib/egress-allowlist.ts` | (none) |
| `LOOPOVER_MINER_SENTRY_ENVIRONMENT` | `lib/sentry.ts` | `"production"` |
| `LOOPOVER_MINER_VERSION` | `lib/version.ts` | `""` |
| `LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB` | `lib/worktree-allocator.ts` | (none) |
Expand Down
Loading
Loading