diff --git a/bundles/browser/manifest.json b/bundles/browser/manifest.json index 6693b84a2..b5409e353 100644 --- a/bundles/browser/manifest.json +++ b/bundles/browser/manifest.json @@ -1,7 +1,7 @@ { "id": "browser", "name": "Browser Automation", - "version": "1.3.5", + "version": "1.3.6", "description": "Stealth browser automation — navigate, fill forms, take screenshots, extract content via Chrome DevTools Protocol with VNC viewing", "type": "bundle", "author": "Crow", @@ -12,6 +12,10 @@ "composefile": "docker-compose.yml" }, "privileged": true, + "install_consent_messages": { + "en": "The browser container uses host networking, so the browser it runs can reach every service on this machine that listens only on 127.0.0.1, as well as the internet. Its control port (Chrome DevTools) listens on 127.0.0.1 only. The VNC viewer (ports 6080 and 5900) listens on every network interface of this machine and is protected by the generated VNC password. It saves downloads under your Crow folder.", + "es": "El contenedor del navegador usa la red del host, así que el navegador que ejecuta puede llegar a todos los servicios de esta máquina que escuchan solo en 127.0.0.1, además de a internet. Su puerto de control (Chrome DevTools) escucha solo en 127.0.0.1. El visor VNC (puertos 6080 y 5900) escucha en todas las interfaces de red de esta máquina y está protegido por la contraseña VNC generada. Guarda las descargas dentro de tu carpeta de Crow." + }, "server": { "command": "node", "args": ["server/index.js"], diff --git a/bundles/companion/docker-compose.yml b/bundles/companion/docker-compose.yml index 8de571d74..86d2c70aa 100644 --- a/bundles/companion/docker-compose.yml +++ b/bundles/companion/docker-compose.yml @@ -11,7 +11,8 @@ services: - companion-models:/app/models - companion-cache:/app/cache - companion-history:/app/chat_history - - ${EIKANYA_MODELS_DIR:-}:/app/live2d-models/eikanya:ro + # Unset → an empty folder inside the bundle (an empty host path is a compose error). + - ${EIKANYA_MODELS_DIR:-./eikanya-models}:/app/live2d-models/eikanya:ro - ${CROW_DB_PATH:-~/.crow/data/crow.db}:/crow-data/crow.db:ro - ${HOME}/.crow/bundles:/crow-bundles:ro environment: diff --git a/bundles/companion/manifest.json b/bundles/companion/manifest.json index 118396223..040094ced 100644 --- a/bundles/companion/manifest.json +++ b/bundles/companion/manifest.json @@ -1,7 +1,12 @@ { "id": "companion", "name": "AI Companion", - "version": "1.0.2", + "version": "1.0.3", + "privileged": true, + "install_consent_messages": { + "en": "The AI Companion uses host networking so it can reach Crow and your local models on this machine. That also lets it reach every service on this machine that listens only on 127.0.0.1. Its own web page listens on 127.0.0.1 by default (COMPANION_BIND_HOST), and Crow serves it to your tailnet over HTTPS. It mounts Crow's database and your installed extensions folder read-only (that folder holds the other extensions' settings and passwords), and it is given Crow's local MCP token.", + "es": "El AI Companion usa la red del host para poder llegar a Crow y a tus modelos locales en esta máquina. Eso también le permite llegar a todos los servicios de esta máquina que escuchan solo en 127.0.0.1. Su propia página web escucha por defecto en 127.0.0.1 (COMPANION_BIND_HOST), y Crow la sirve a tu tailnet por HTTPS. Monta en solo lectura la base de datos de Crow y la carpeta de tus extensiones instaladas (esa carpeta guarda los ajustes y las contraseñas de las demás extensiones), y recibe el token MCP local de Crow." + }, "description": "Voice-interactive AI companion with animated Live2D avatar. Powered by Open-LLM-VTuber with Crow's BYOAI profiles.", "type": "bundle", "author": "Crow", diff --git a/bundles/coturn/manifest.json b/bundles/coturn/manifest.json index bb278c3b7..0ff2b5fb9 100644 --- a/bundles/coturn/manifest.json +++ b/bundles/coturn/manifest.json @@ -1,7 +1,12 @@ { "id": "coturn", "name": "TURN Server (coturn)", - "version": "1.0.1", + "version": "1.0.2", + "privileged": true, + "install_consent_messages": { + "en": "The TURN relay uses host networking because it relays calls over a range of UDP ports (49152-49252) as well as ports 3478 and 5349. It listens on every network interface of this machine so devices outside your network can reach it. Only callers holding a short-lived credential from Crow can relay through it, but a caller who holds one can relay traffic to addresses on your local network and tailnet too.", + "es": "El relé TURN usa la red del host porque retransmite llamadas por un rango de puertos UDP (49152-49252) además de los puertos 3478 y 5349. Escucha en todas las interfaces de red de esta máquina para que los dispositivos fuera de tu red puedan alcanzarlo. Solo quien tenga una credencial temporal de Crow puede retransmitir a través de él, pero quien tenga una también puede retransmitir tráfico hacia direcciones de tu red local y de tu tailnet." + }, "description": "Self-hosted TURN relay for WebRTC voice chat. Enables NAT traversal for remote companion access when STUN alone is not sufficient.", "type": "bundle", "author": "Crow", diff --git a/bundles/forgejo/docker-compose.yml b/bundles/forgejo/docker-compose.yml index 0a5007918..09d15b9fa 100644 --- a/bundles/forgejo/docker-compose.yml +++ b/bundles/forgejo/docker-compose.yml @@ -23,7 +23,6 @@ services: - "127.0.0.1:2224:22" volumes: - ${FORGEJO_DATA_DIR:-~/.crow/forgejo/data}:/data - - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro environment: - USER_UID=1000 diff --git a/bundles/forgejo/manifest.json b/bundles/forgejo/manifest.json index bfedf9b99..522665d9a 100644 --- a/bundles/forgejo/manifest.json +++ b/bundles/forgejo/manifest.json @@ -1,7 +1,7 @@ { "id": "forgejo", "name": "Forgejo", - "version": "1.0.0", + "version": "1.0.1", "description": "Community-driven soft-fork of Gitea — self-hosted git with an independent governance model and ActivityPub federation roadmap", "type": "bundle", "author": "Crow", diff --git a/bundles/frigate/docker-compose.yml b/bundles/frigate/docker-compose.yml index a68096d47..c9f04f930 100644 --- a/bundles/frigate/docker-compose.yml +++ b/bundles/frigate/docker-compose.yml @@ -7,9 +7,10 @@ services: - "127.0.0.1:8971:8971" # RTSP restreamer (cameras pull from this for multi-consumer restreaming) - "127.0.0.1:8554:8554" - # WebRTC (low-latency live view). Both TCP and UDP needed; bind to all interfaces for LAN access - - "8555:8555/tcp" - - "8555:8555/udp" + # WebRTC (low-latency live view). Both TCP and UDP. Loopback only, like every + # other Crow port: other devices watch through the authenticated UI above. + - "127.0.0.1:8555:8555/tcp" + - "127.0.0.1:8555:8555/udp" # Port 5000 (internal unauthenticated API) intentionally NOT published — would bypass auth. volumes: - ${FRIGATE_CONFIG_PATH:-frigate-config}:/config diff --git a/bundles/frigate/manifest.json b/bundles/frigate/manifest.json index 4b34a7f56..2c87c9b7b 100644 --- a/bundles/frigate/manifest.json +++ b/bundles/frigate/manifest.json @@ -1,7 +1,7 @@ { "id": "frigate", "name": "Frigate NVR", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted NVR with AI object detection — cameras, events, clips. RTSP/ONVIF cameras, local CPU detection, optional Coral TPU.", "type": "bundle", "author": "Crow", diff --git a/bundles/gitea/docker-compose.yml b/bundles/gitea/docker-compose.yml index 1a8dcd315..6c10fc776 100644 --- a/bundles/gitea/docker-compose.yml +++ b/bundles/gitea/docker-compose.yml @@ -24,7 +24,6 @@ services: - "127.0.0.1:2223:22" volumes: - ${GITEA_DATA_DIR:-~/.crow/gitea/data}:/data - - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro environment: - USER_UID=1000 diff --git a/bundles/gitea/manifest.json b/bundles/gitea/manifest.json index 1a292eda2..3bca2e5c6 100644 --- a/bundles/gitea/manifest.json +++ b/bundles/gitea/manifest.json @@ -1,7 +1,7 @@ { "id": "gitea", "name": "Gitea", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted git service — repos, issues, pull requests, and built-in SSH server for git over SSH", "type": "bundle", "author": "Crow", diff --git a/bundles/netdata/docker-compose.yml b/bundles/netdata/docker-compose.yml index 7199ebb42..cd89d7956 100644 --- a/bundles/netdata/docker-compose.yml +++ b/bundles/netdata/docker-compose.yml @@ -11,8 +11,10 @@ # platform blocks /etc and we accept reduced hostname/OS metadata). # # NOT in the MVP: -# - SYS_PTRACE capability + pid:host (per-process stats). Would require -# privileged:true, deliberately excluded for MVP scope. +# - the process-tracing capability + a shared host PID namespace (per-process +# stats). Would require privileged:true, deliberately excluded for MVP scope. +# (Not named literally here: the install security check reads this file as +# text, comments included.) services: netdata: diff --git a/bundles/netdata/manifest.json b/bundles/netdata/manifest.json index 035d2a90c..88a1b7ccc 100644 --- a/bundles/netdata/manifest.json +++ b/bundles/netdata/manifest.json @@ -1,7 +1,7 @@ { "id": "netdata", "name": "Netdata", - "version": "1.0.0", + "version": "1.0.1", "description": "Real-time infrastructure and container monitoring — CPU, memory, disk, network, and Docker container metrics with one-second granularity.", "type": "bundle", "author": "Crow", diff --git a/bundles/nominatim/docker-compose.yml b/bundles/nominatim/docker-compose.yml index 1c776ec47..5991cab6a 100644 --- a/bundles/nominatim/docker-compose.yml +++ b/bundles/nominatim/docker-compose.yml @@ -11,7 +11,7 @@ services: - nominatim-data:/var/lib/postgresql/14/main - nominatim-flat:/nominatim/flatnode ports: - - "${NOMINATIM_PORT:-8088}:8080" + - "127.0.0.1:${NOMINATIM_PORT:-8088}:8080" mem_limit: 4g shm_size: 1g healthcheck: diff --git a/bundles/nominatim/manifest.json b/bundles/nominatim/manifest.json index 280609749..478be711b 100644 --- a/bundles/nominatim/manifest.json +++ b/bundles/nominatim/manifest.json @@ -1,7 +1,7 @@ { "id": "nominatim", "name": "Nominatim GIS", - "version": "1.0.2", + "version": "1.0.3", "description": "Self-hosted geocoding and GIS — forward/reverse geocode, batch geocode data columns, generate GeoJSON, map visualizations", "type": "bundle", "author": "Crow", diff --git a/bundles/phone/manifest.json b/bundles/phone/manifest.json index dac91b4bd..ac6fe4aaf 100644 --- a/bundles/phone/manifest.json +++ b/bundles/phone/manifest.json @@ -1,7 +1,7 @@ { "id": "phone", "name": "Phone", - "version": "0.2.4", + "version": "0.2.5", "description": "Your assistant calls businesses for you: book appointments and ask questions, with your approval on every call.", "type": "bundle", "author": "Crow", @@ -13,7 +13,8 @@ "panelRoutes": "panel/routes.js", "privileged": true, "install_consent_messages": { - "en": "The phone runner uses host networking, bound to 127.0.0.1 only, so it can reach Crow and your local models. It opens no network ports to other machines." + "en": "The phone runner uses host networking, bound to 127.0.0.1 only, so it can reach Crow and your local models. It opens no network ports to other machines.", + "es": "El ejecutor del teléfono usa la red del host, vinculado solo a 127.0.0.1, para poder llegar a Crow y a tus modelos locales. No abre puertos de red hacia otras máquinas." }, "ports": [3065], "requires": { "env": ["PHONE_RUNNER_SECRET"], "min_ram_mb": 256, "min_disk_mb": 200 }, diff --git a/bundles/phone/server/mcp.js b/bundles/phone/server/mcp.js index ccef4e0be..f61e59ef7 100644 --- a/bundles/phone/server/mcp.js +++ b/bundles/phone/server/mcp.js @@ -72,7 +72,7 @@ export function createPhoneMcpServer({ db, ownerNumber, McpServer, z, notify, no catch (e) { console.warn(`[phone] card push failed for ${id}: ${e.message}`); } }; if (!McpServer || !z) throw new Error("createPhoneMcpServer needs the gateway's McpServer and z (dependency injection)"); - const server = new McpServer({ name: "crow-phone", version: "0.2.4" }); + const server = new McpServer({ name: "crow-phone", version: "0.2.5" }); server.tool("phone_plan_call", "Propose a phone call to a BUSINESS for the owner. This never dials: the owner must approve the plan (in this chat's call card, or in Crow's Nest → Phone). Give the goal, the limits the agent may agree to, and only the personal details the business needs.", diff --git a/bundles/plex/manifest.json b/bundles/plex/manifest.json index e9cfed126..c8d766d2b 100644 --- a/bundles/plex/manifest.json +++ b/bundles/plex/manifest.json @@ -1,7 +1,12 @@ { "id": "plex", "name": "Plex", - "version": "1.0.1", + "version": "1.0.2", + "privileged": true, + "install_consent_messages": { + "en": "Crow does not start a Plex server at install: it runs only if you start it from Extensions. If you do, the self-hosted Plex server uses host networking (Plex's own recommendation) so devices on your local network can discover it. It listens on every network interface of this machine (port 32400 and Plex's discovery ports) and mounts your media folder read-write. Until you claim it with your Plex account (PLEX_CLAIM_TOKEN), the first person who opens it can claim it.", + "es": "Crow no inicia un servidor Plex al instalar: solo se ejecuta si lo inicias desde Extensiones. Si lo haces, el servidor Plex autoalojado usa la red del host (lo que recomienda Plex) para que los dispositivos de tu red local puedan descubrirlo. Escucha en todas las interfaces de red de esta máquina (el puerto 32400 y los puertos de descubrimiento de Plex) y monta tu carpeta de medios con lectura y escritura. Hasta que lo reclames con tu cuenta de Plex (PLEX_CLAIM_TOKEN), la primera persona que lo abra puede reclamarlo." + }, "description": "Connect to Plex Media Server — search library, browse collections, control playback, view active sessions", "type": "mcp-server", "author": "Crow", diff --git a/bundles/rookery/.env.example b/bundles/rookery/.env.example index f0a0a0613..0ff7d02aa 100644 --- a/bundles/rookery/.env.example +++ b/bundles/rookery/.env.example @@ -11,7 +11,7 @@ ROOKERY_MODEL_API_KEY=local # serving layer you put in front of 127.0.0.1:3061 is the boundary). ROOKERY_CORS_ORIGINS= # Host dir where audit workspaces are assembled (shared with the MCP tool) -ROOKERY_WORKSPACES_DIR=~/.crow/data/rookery/workspaces +ROOKERY_WORKSPACES_DIR=~/.crow/data/rookery-workspaces # Where the reviewer UI is reachable on YOUR deployment (root-origin serving: # a Tailscale Serve HTTPS port, a reverse proxy, or an ssh -L tunnel to # 127.0.0.1:3061). Shown by the panel and the MCP tool. Empty = local default. diff --git a/bundles/rookery/README.md b/bundles/rookery/README.md index b4ddfce57..cd78a9c66 100644 --- a/bundles/rookery/README.md +++ b/bundles/rookery/README.md @@ -29,7 +29,7 @@ host.docker.internal:host-gateway`) but the connection times out; verified on a live install 2026-07-14), `ROOKERY_MODEL_ID` (as reported by the endpoint's `/v1/models`), `ROOKERY_MODEL_API_KEY` (only if your endpoint checks it; default `local`), -`ROOKERY_WORKSPACES_DIR` (default `~/.crow/data/rookery/workspaces` is +`ROOKERY_WORKSPACES_DIR` (default `~/.crow/data/rookery-workspaces`, beside the bundle data folder, not inside it, is usually fine), and `ROOKERY_CORS_ORIGINS`/`ROOKERY_REVIEWER_URL` — see below. Host prerequisite: the `rookery_assemble_exp` MCP tool and the panel's assemble form both run via `uv` at `~/.local/bin/uv` (rookery is the first diff --git a/bundles/rookery/docker-compose.yml b/bundles/rookery/docker-compose.yml index 307949bb1..1e1f09879 100644 --- a/bundles/rookery/docker-compose.yml +++ b/bundles/rookery/docker-compose.yml @@ -14,7 +14,7 @@ services: MCP_RESEARCH_URL: ${ROOKERY_MCP_RESEARCH_URL:-} volumes: - ${HOME}/.crow/data/rookery:/data - - ${ROOKERY_WORKSPACES_DIR:-${HOME}/.crow/data/rookery/workspaces}:/workspaces + - ${ROOKERY_WORKSPACES_DIR:-${HOME}/.crow/data/rookery-workspaces}:/workspaces ports: - "127.0.0.1:3061:3061" extra_hosts: diff --git a/bundles/rookery/manifest.json b/bundles/rookery/manifest.json index 1e9fce971..79afe2759 100644 --- a/bundles/rookery/manifest.json +++ b/bundles/rookery/manifest.json @@ -2,7 +2,7 @@ "id": "rookery", "name": "Open Science Reviewer", "origin": "community", - "version": "0.1.1", + "version": "0.1.2", "description": "Self-hosted OpenScience reviewer + experiment-audit workspace assembler — audit a report's numeric claims against its registered evidence, on your own local model", "type": "bundle", "author": "kh0pper", @@ -22,7 +22,7 @@ { "name": "ROOKERY_MODEL_BASE_URL", "description": "OpenAI-compatible endpoint for the reviewer model (e.g. a local llama.cpp/vLLM server; find your configured endpoint in Crow's AI settings). If the host service binds a SPECIFIC IP (e.g. a Tailscale address), use that IP directly: http://:/v1 — host.docker.internal only reaches services listening on 0.0.0.0 or the Docker bridge, and times out otherwise (verified 2026-07-14)", "required": true }, { "name": "ROOKERY_MODEL_ID", "description": "Model id as reported by the endpoint's /v1/models", "default": "local-model", "required": true }, { "name": "ROOKERY_MODEL_API_KEY", "description": "API key if the endpoint needs one (local servers usually ignore it)", "default": "local", "required": false, "secret": true }, - { "name": "ROOKERY_WORKSPACES_DIR", "description": "Host directory where audit workspaces are assembled (shared by the reviewer and the assemble tool)", "default": "~/.crow/data/rookery/workspaces", "required": false }, + { "name": "ROOKERY_WORKSPACES_DIR", "description": "Host directory where audit workspaces are assembled (shared by the reviewer and the assemble tool)", "default": "~/.crow/data/rookery-workspaces", "required": false }, { "name": "ROOKERY_CORS_ORIGINS", "description": "Space-separated browser origins for the app's own CORS/CSRF whitelist. RECOMMENDED: set to your serving origin when the reviewer is served beyond localhost. Empty = shim strips Origin (fine for tunnel/localhost use; disables the app's origin check)", "required": false }, { "name": "ROOKERY_REVIEWER_URL", "description": "Where the reviewer UI is reachable on your deployment (serve 127.0.0.1:3061 at a ROOT origin: Tailscale Serve HTTPS port, reverse proxy, or ssh -L tunnel). Shown by the panel and the assemble tool", "required": false }, { "name": "UV_NO_CACHE", "description": "Disable uv's download cache for the host MCP process (prevents multi-GB cache growth)", "default": "1", "required": false }, diff --git a/bundles/rookery/panel/routes.js b/bundles/rookery/panel/routes.js index 56a1083ff..466bb08d9 100644 --- a/bundles/rookery/panel/routes.js +++ b/bundles/rookery/panel/routes.js @@ -5,7 +5,7 @@ import { join } from "node:path"; import os from "node:os"; const WORKSPACES = () => - (process.env.ROOKERY_WORKSPACES_DIR || join(os.homedir(), ".crow/data/rookery/workspaces")) + (process.env.ROOKERY_WORKSPACES_DIR || join(os.homedir(), ".crow/data/rookery-workspaces")) .replace(/^~(?=\/)/, os.homedir()); // Root-origin serving (plan deviation 4): the reviewer is NOT behind /proxy//. // Scheme allowlist: the value lands in anchor hrefs in the tile — a non-http(s) diff --git a/bundles/sdxl/docker-compose.yml b/bundles/sdxl/docker-compose.yml index 7e266acaf..998e2539e 100644 --- a/bundles/sdxl/docker-compose.yml +++ b/bundles/sdxl/docker-compose.yml @@ -7,7 +7,7 @@ services: container_name: crow-sdxl restart: unless-stopped ports: - - "${SDXL_PORT:-3005}:3005" + - "127.0.0.1:${SDXL_PORT:-3005}:3005" volumes: - sdxl-backgrounds:/app/backgrounds - sdxl-models:/app/models diff --git a/bundles/sdxl/manifest.json b/bundles/sdxl/manifest.json index 5b6b57560..2833fbea7 100644 --- a/bundles/sdxl/manifest.json +++ b/bundles/sdxl/manifest.json @@ -1,7 +1,7 @@ { "id": "sdxl", "name": "SDXL Background Generator", - "version": "1.0.0", + "version": "1.0.1", "description": "AI-generated backgrounds for the companion avatar using SDXL Turbo. Requires NVIDIA GPU.", "type": "bundle", "author": "Crow", diff --git a/bundles/tailscale/manifest.json b/bundles/tailscale/manifest.json index eb54240cd..358f5c43b 100644 --- a/bundles/tailscale/manifest.json +++ b/bundles/tailscale/manifest.json @@ -1,7 +1,12 @@ { "id": "tailscale", "name": "Tailscale", - "version": "1.0.0", + "version": "1.0.1", + "privileged": true, + "install_consent_messages": { + "en": "The Tailscale container joins this machine to your tailnet. It uses host networking, the NET_ADMIN capability and the /dev/net/tun device, so it creates a network interface and changes this machine's routing and firewall rules. Do not install it on a machine that already runs Tailscale.", + "es": "El contenedor de Tailscale une esta máquina a tu tailnet. Usa la red del host, la capacidad NET_ADMIN y el dispositivo /dev/net/tun, así que crea una interfaz de red y cambia las rutas y las reglas de firewall de esta máquina. No lo instales en una máquina que ya ejecuta Tailscale." + }, "description": "Private network access — reach your Crow from any device without exposing it to the public internet", "type": "bundle", "author": "Crow", diff --git a/bundles/vllm/docker-compose.yml b/bundles/vllm/docker-compose.yml index cf10dc963..bacecea8e 100644 --- a/bundles/vllm/docker-compose.yml +++ b/bundles/vllm/docker-compose.yml @@ -15,7 +15,9 @@ services: capabilities: [gpu] ports: - "127.0.0.1:${VLLM_HTTP_PORT:-8089}:8000" - ipc: host + # Shared memory for PyTorch workers. vLLM documents ipc: host OR a large shm; shm keeps + # the host IPC namespace private (the install check refuses ipc: host without consent). + shm_size: "${VLLM_SHM_SIZE:-8gb}" environment: - HUGGING_FACE_HUB_TOKEN=${VLLM_HF_TOKEN:-} - VLLM_NO_USAGE_STATS=1 diff --git a/bundles/vllm/manifest.json b/bundles/vllm/manifest.json index 1712218a7..3237f99df 100644 --- a/bundles/vllm/manifest.json +++ b/bundles/vllm/manifest.json @@ -1,7 +1,7 @@ { "id": "vllm", "name": "vLLM", - "version": "1.0.0", + "version": "1.0.1", "description": "GPU-accelerated local LLM inference server \u2014 OpenAI-compatible endpoint backed by vLLM's continuous batching and PagedAttention. Recommended classroom engine for Maker Lab: the Phase 0 Ollama benchmark hit unusable p95 latencies at N=25 concurrent learners; vLLM is architecturally the right shape for that scale.", "type": "bundle", "author": "Crow", diff --git a/docs/developers/addon-registry.md b/docs/developers/addon-registry.md index c86b74bc4..b9a751574 100644 --- a/docs/developers/addon-registry.md +++ b/docs/developers/addon-registry.md @@ -72,7 +72,7 @@ The registry is a JSON file at `registry/add-ons.json`: | `requires.min_disk_mb` | No | Minimum disk space in MB | | `requires.gpu` | No | Set `true` if the add-on needs a GPU | | `requires.bundles` | No | Array of bundle IDs that must be installed first. Install endpoint refuses if any are missing; uninstall is blocked while dependents are installed. | -| `privileged` | No | Set `true` for bundles that need NET_ADMIN, NET_RAW, SYS_ADMIN, or `network_mode: host`. Triggers the install-modal consent flow with a server-validated single-use token. | +| `privileged` | No | Set `true` for bundles that need NET_ADMIN, NET_RAW, SYS_ADMIN, or `network_mode: host`. Triggers the install-modal consent flow with a server-validated single-use token. `tests/bundle-compose-gate.test.js` runs the install security check over every first-party compose file, so a bundle that needs one of these and does not declare it fails CI. | | `consent_required` | No | Set `true` for bundles with significant operational cost or read-Docker-socket access (netdata, dozzle). Triggers the consent modal even if not `privileged`. | | `install_consent_messages` | No | Object keyed by language code (`en`, `es`, ...) with the warning text shown in the install-confirmation modal. Falls back to `install_consent_message` then a generic string. | | `install_consent_message` | No | Single-language fallback for `install_consent_messages`. | diff --git a/docs/developers/community-stores.md b/docs/developers/community-stores.md index 0168bede2..4be1fe7b8 100644 --- a/docs/developers/community-stores.md +++ b/docs/developers/community-stores.md @@ -56,12 +56,13 @@ Community stores have additional restrictions compared to the official registry: ### Compose File Validation -Before installing a community add-on, Crow validates the `docker-compose.yml`: +Before installing a community add-on, and again every time Crow starts its containers, Crow checks the compose project. It asks Docker Compose for the configuration it will actually apply (`docker compose config`: anchors, `include`, `extends`, override files and `${VAR}` values resolved) and checks that, following symlinks. If Compose can't read the project, the install is refused. -- **Rejected**: mounts to `/`, `/etc`, `~/.ssh`, `~/.crow/data`, or any host path outside `~/.crow/bundles//data` -- **Rejected**: `privileged: true` -- **Rejected**: `NET_ADMIN` or `SYS_ADMIN` capabilities -- **Allowed**: named Docker volumes, `~/.crow/bundles//data` +- **Rejected**: host mounts of `/`, your home folder, `/etc`, `/root`, `/boot`, `/run`, `/dev`, `~/.ssh`, Docker's own data, Crow's folder, data folder, secrets folder and `.env`, other extensions' folders, or any parent of these. Exceptions: `/etc/localtime` read-only; the add-on's own `/` subfolder +- **Rejected**: `env_file`, `secrets`/`configs` files and the build context outside the add-on's own folder +- **Rejected**: a second compose file (`compose.yaml`, `docker-compose.override.yml`, …) shipped next to `docker-compose.yml`, `include:`/`extends: file:`, and `COMPOSE_*` settings in its `.env` +- **Rejected for community add-ons, and only with consent for first-party ones**: `privileged`, dangerous capabilities (`NET_ADMIN`, `SYS_ADMIN`, `ALL`, …), host or another container's network, the host's PID/IPC/user namespaces, `unconfined` security options, `volumes_from`, and host devices other than GPU render nodes +- **Allowed**: named Docker volumes and paths inside the add-on's folder ### Network Isolation diff --git a/docs/developers/port-allocation.md b/docs/developers/port-allocation.md index 5df8e93fe..030e2de11 100644 --- a/docs/developers/port-allocation.md +++ b/docs/developers/port-allocation.md @@ -47,6 +47,7 @@ These predate this registry and need follow-up resolution outside the MVP scope: | 3001 | 127.0.0.1 | Crow gateway (HTTPS) | core | | 3002 | 127.0.0.1 | Crow gateway (alt) | core | | 3004 | 127.0.0.1 | Crow gateway (alt) | core | +| 3005 | 127.0.0.1 | sdxl (overridable via `SDXL_PORT`; companion reaches it at localhost over host networking) | existing | | 3007 | 127.0.0.1 | uptime-kuma | MVP PR 1 | | 3008 | 127.0.0.1 | Crow gateway (alt) | core | | 3020 | 127.0.0.1 | adguard-home (admin UI) | MVP PR 3 | @@ -88,6 +89,7 @@ These predate this registry and need follow-up resolution outside the MVP scope: | 8085 | 127.0.0.1 | miniflux (existing) | existing | | 8086 | 127.0.0.1 | shiori (existing) | existing | | 8088 | 127.0.0.1 | trilium (existing) | existing | +| 8088 | 127.0.0.1 | nominatim default (`NOMINATIM_PORT`) — **collides with trilium**; set `NOMINATIM_PORT` if you run both | existing | | 8089 | 127.0.0.1 + tailscale IP | edjobs nominatim (external, ~/ed-jobs-scraper) — R4 GIS tools + grackle scraper stack (ufw-scoped) | external | | 8090 | 127.0.0.1 | (reserved — a private bundle maintained outside this repo) | reserved | | 8091 | 127.0.0.1 | crowdsec (LAPI) | MVP PR 4 | diff --git a/docs/es/developers/addon-registry.md b/docs/es/developers/addon-registry.md index da0d36e08..7d7e3adff 100644 --- a/docs/es/developers/addon-registry.md +++ b/docs/es/developers/addon-registry.md @@ -72,7 +72,7 @@ El registro es un archivo JSON en `registry/add-ons.json`: | `requires.min_disk_mb` | No | Espacio mínimo en disco en MB | | `requires.gpu` | No | Establece `true` si el complemento necesita una GPU | | `requires.bundles` | No | Arreglo de IDs de bundles que deben instalarse primero. El endpoint de instalación se niega si falta alguno; la desinstalación se bloquea mientras haya dependientes instalados. | -| `privileged` | No | Establece `true` para bundles que necesitan NET_ADMIN, NET_RAW, SYS_ADMIN o `network_mode: host`. Activa el flujo de consentimiento del modal de instalación con un token de un solo uso validado por el servidor. | +| `privileged` | No | Establece `true` para bundles que necesitan NET_ADMIN, NET_RAW, SYS_ADMIN o `network_mode: host`. Activa el flujo de consentimiento del modal de instalación con un token de un solo uso validado por el servidor. `tests/bundle-compose-gate.test.js` ejecuta la comprobación de seguridad de instalación sobre cada archivo compose propio, así que un bundle que necesita una de estas cosas y no lo declara falla en CI. | | `consent_required` | No | Establece `true` para bundles con un costo operativo significativo o con acceso de lectura al socket de Docker (netdata, dozzle). Activa el modal de consentimiento aunque no sean `privileged`. | | `install_consent_messages` | No | Objeto indexado por código de idioma (`en`, `es`, ...) con el texto de advertencia mostrado en el modal de confirmación de instalación. Recurre a `install_consent_message` y luego a una cadena genérica. | | `install_consent_message` | No | Alternativa de un solo idioma para `install_consent_messages`. | diff --git a/docs/es/developers/community-stores.md b/docs/es/developers/community-stores.md index 240a90ef4..57038f712 100644 --- a/docs/es/developers/community-stores.md +++ b/docs/es/developers/community-stores.md @@ -56,12 +56,13 @@ Las tiendas de la comunidad tienen restricciones adicionales en comparación con ### Validación del archivo compose -Antes de instalar un complemento de la comunidad, Crow valida el `docker-compose.yml`: +Antes de instalar un complemento de la comunidad, y de nuevo cada vez que Crow inicia sus contenedores, Crow revisa el proyecto compose. Le pide a Docker Compose la configuración que de verdad aplicará (`docker compose config`: anclas, `include`, `extends`, archivos override y valores `${VAR}` resueltos) y revisa eso, siguiendo los enlaces simbólicos. Si Compose no puede leer el proyecto, la instalación se rechaza. -- **Rechazado**: montajes a `/`, `/etc`, `~/.ssh`, `~/.crow/data` o cualquier ruta del host fuera de `~/.crow/bundles//data` -- **Rechazado**: `privileged: true` -- **Rechazado**: las capacidades `NET_ADMIN` o `SYS_ADMIN` -- **Permitido**: volúmenes de Docker con nombre, `~/.crow/bundles//data` +- **Rechazado**: montajes del host de `/`, tu carpeta personal, `/etc`, `/root`, `/boot`, `/run`, `/dev`, `~/.ssh`, los datos propios de Docker, la carpeta de Crow, su carpeta de datos, su carpeta de secretos y su `.env`, las carpetas de otras extensiones, o cualquier carpeta que contenga a estas. Excepciones: `/etc/localtime` en solo lectura; la subcarpeta propia del complemento `/` +- **Rechazado**: `env_file`, archivos de `secrets`/`configs` y el contexto de build fuera de la carpeta propia del complemento +- **Rechazado**: un segundo archivo compose (`compose.yaml`, `docker-compose.override.yml`, …) junto a `docker-compose.yml`, `include:`/`extends: file:` y ajustes `COMPOSE_*` en su `.env` +- **Rechazado para complementos de la comunidad, y solo con consentimiento para los propios**: `privileged`, capacidades peligrosas (`NET_ADMIN`, `SYS_ADMIN`, `ALL`, …), la red del host o la de otro contenedor, los espacios de nombres PID/IPC/de usuario del host, opciones de seguridad `unconfined`, `volumes_from` y dispositivos del host que no sean los nodos de render de la GPU +- **Permitido**: volúmenes de Docker con nombre y rutas dentro de la carpeta del complemento ### Aislamiento de red diff --git a/docs/es/developers/port-allocation.md b/docs/es/developers/port-allocation.md index a1d989d14..57b9c6936 100644 --- a/docs/es/developers/port-allocation.md +++ b/docs/es/developers/port-allocation.md @@ -42,6 +42,7 @@ Estos son anteriores a este registro y necesitan una resolución de seguimiento | 3001 | 127.0.0.1 | gateway de Crow (HTTPS) | core | | 3002 | 127.0.0.1 | gateway de Crow (alterno) | core | | 3004 | 127.0.0.1 | gateway de Crow (alterno) | core | +| 3005 | 127.0.0.1 | sdxl (se cambia con `SDXL_PORT`; companion lo alcanza en localhost por la red del host) | existente | | 3007 | 127.0.0.1 | uptime-kuma | MVP PR 1 | | 3020 | 127.0.0.1 | adguard-home (UI de administración) | MVP PR 3 | | 3030 | 127.0.0.1 | homepage | MVP PR 1 | @@ -70,6 +71,7 @@ Estos son anteriores a este registro y necesitan una resolución de seguimiento | 8085 | 127.0.0.1 | miniflux (existente) | existente | | 8086 | 127.0.0.1 | shiori (existente) | existente | | 8088 | 127.0.0.1 | trilium (existente) | existente | +| 8088 | 127.0.0.1 | valor por defecto de nominatim (`NOMINATIM_PORT`) — **choca con trilium**; define `NOMINATIM_PORT` si usas ambos | existente | | 8091 | 127.0.0.1 | crowdsec (LAPI) | MVP PR 4 | | 8092 | 127.0.0.1 | stirling-pdf | MVP PR 1 | | 8094 | 127.0.0.1 | gatus | MVP PR 2 | diff --git a/docs/es/integrations/frigate.md b/docs/es/integrations/frigate.md index 1dfdaf809..fc35cee7a 100644 --- a/docs/es/integrations/frigate.md +++ b/docs/es/integrations/frigate.md @@ -40,7 +40,7 @@ Esto ejecuta Frigate en Docker usando la imagen oficial `ghcr.io/blakeblackshear - `:8971` — interfaz + API REST autenticadas (solo loopback) - `:8554` — restreamer RTSP (solo loopback) -- `:8555` — WebRTC para vista en vivo de baja latencia (LAN) +- `:8555` — WebRTC para vista en vivo de baja latencia (solo loopback, como todos los puertos de Crow; los demás dispositivos miran a través de la interfaz autenticada) El puerto `5000` (la API interna no autenticada de Frigate) **intencionalmente no se publica**. diff --git a/docs/integrations/frigate.md b/docs/integrations/frigate.md index 083c27d38..e7f53f937 100644 --- a/docs/integrations/frigate.md +++ b/docs/integrations/frigate.md @@ -40,7 +40,7 @@ This runs Frigate in Docker using the official `ghcr.io/blakeblackshear/frigate: - `:8971` — authenticated UI + REST API (loopback only) - `:8554` — RTSP restreamer (loopback only) -- `:8555` — WebRTC for low-latency live view (LAN) +- `:8555` — WebRTC for low-latency live view (loopback only, like every Crow port; other devices watch through the authenticated UI) Port `5000` (Frigate's internal unauthenticated API) is **intentionally not published**. diff --git a/registry/add-ons.json b/registry/add-ons.json index 2680546d2..e5ce2671b 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -365,7 +365,7 @@ { "id": "browser", "name": "Browser Automation", - "version": "1.3.5", + "version": "1.3.6", "description": "Stealth browser automation — navigate, fill forms, take screenshots, extract content via Chrome DevTools Protocol with VNC viewing", "type": "bundle", "author": "Crow", @@ -383,6 +383,10 @@ "composefile": "docker-compose.yml" }, "privileged": true, + "install_consent_messages": { + "en": "The browser container uses host networking, so the browser it runs can reach every service on this machine that listens only on 127.0.0.1, as well as the internet. Its control port (Chrome DevTools) listens on 127.0.0.1 only. The VNC viewer (ports 6080 and 5900) listens on every network interface of this machine and is protected by the generated VNC password. It saves downloads under your Crow folder.", + "es": "El contenedor del navegador usa la red del host, así que el navegador que ejecuta puede llegar a todos los servicios de esta máquina que escuchan solo en 127.0.0.1, además de a internet. Su puerto de control (Chrome DevTools) escucha solo en 127.0.0.1. El visor VNC (puertos 6080 y 5900) escucha en todas las interfaces de red de esta máquina y está protegido por la contraseña VNC generada. Guarda las descargas dentro de tu carpeta de Crow." + }, "server": { "command": "node", "args": [ @@ -841,7 +845,12 @@ { "id": "companion", "name": "AI Companion", - "version": "1.0.2", + "version": "1.0.3", + "privileged": true, + "install_consent_messages": { + "en": "The AI Companion uses host networking so it can reach Crow and your local models on this machine. That also lets it reach every service on this machine that listens only on 127.0.0.1. Its own web page listens on 127.0.0.1 by default (COMPANION_BIND_HOST), and Crow serves it to your tailnet over HTTPS. It mounts Crow's database and your installed extensions folder read-only (that folder holds the other extensions' settings and passwords), and it is given Crow's local MCP token.", + "es": "El AI Companion usa la red del host para poder llegar a Crow y a tus modelos locales en esta máquina. Eso también le permite llegar a todos los servicios de esta máquina que escuchan solo en 127.0.0.1. Su propia página web escucha por defecto en 127.0.0.1 (COMPANION_BIND_HOST), y Crow la sirve a tu tailnet por HTTPS. Monta en solo lectura la base de datos de Crow y la carpeta de tus extensiones instaladas (esa carpeta guarda los ajustes y las contraseñas de las demás extensiones), y recibe el token MCP local de Crow." + }, "description": "Voice-interactive AI companion with animated Live2D avatar. Powered by Open-LLM-VTuber with Crow's BYOAI profiles.", "type": "bundle", "author": "Crow", @@ -920,7 +929,12 @@ { "id": "coturn", "name": "TURN Server (coturn)", - "version": "1.0.1", + "version": "1.0.2", + "privileged": true, + "install_consent_messages": { + "en": "The TURN relay uses host networking because it relays calls over a range of UDP ports (49152-49252) as well as ports 3478 and 5349. It listens on every network interface of this machine so devices outside your network can reach it. Only callers holding a short-lived credential from Crow can relay through it, but a caller who holds one can relay traffic to addresses on your local network and tailnet too.", + "es": "El relé TURN usa la red del host porque retransmite llamadas por un rango de puertos UDP (49152-49252) además de los puertos 3478 y 5349. Escucha en todas las interfaces de red de esta máquina para que los dispositivos fuera de tu red puedan alcanzarlo. Solo quien tenga una credencial temporal de Crow puede retransmitir a través de él, pero quien tenga una también puede retransmitir tráfico hacia direcciones de tu red local y de tu tailnet." + }, "description": "Self-hosted TURN relay for WebRTC voice chat. Enables NAT traversal for remote companion access when STUN alone is not sufficient.", "type": "bundle", "author": "Crow", @@ -1222,7 +1236,7 @@ { "id": "forgejo", "name": "Forgejo", - "version": "1.0.0", + "version": "1.0.1", "description": "Community-driven soft-fork of Gitea — self-hosted git with an independent governance model and ActivityPub federation roadmap", "type": "bundle", "author": "Crow", @@ -1290,7 +1304,7 @@ { "id": "frigate", "name": "Frigate NVR", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted NVR with AI object detection — cameras, events, clips. RTSP/ONVIF cameras, local CPU detection, optional Coral TPU.", "type": "bundle", "author": "Crow", @@ -1662,7 +1676,7 @@ { "id": "gitea", "name": "Gitea", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted git service — repos, issues, pull requests, and built-in SSH server for git over SSH", "type": "bundle", "author": "Crow", @@ -4095,7 +4109,7 @@ { "id": "netdata", "name": "Netdata", - "version": "1.0.0", + "version": "1.0.1", "description": "Real-time infrastructure and container monitoring — CPU, memory, disk, network, and Docker container metrics with one-second granularity.", "type": "bundle", "author": "Crow", @@ -4204,7 +4218,7 @@ { "id": "nominatim", "name": "Nominatim GIS", - "version": "1.0.2", + "version": "1.0.3", "description": "Self-hosted geocoding and GIS — forward/reverse geocode, batch geocode data columns, generate GeoJSON, map visualizations", "type": "bundle", "author": "Crow", @@ -4649,7 +4663,7 @@ { "id": "phone", "name": "Phone", - "version": "0.2.4", + "version": "0.2.5", "description": "Your assistant calls businesses for you: book appointments and ask questions, with your approval on every call.", "type": "bundle", "author": "Crow", @@ -4668,7 +4682,8 @@ "panelRoutes": "panel/routes.js", "privileged": true, "install_consent_messages": { - "en": "The phone runner uses host networking, bound to 127.0.0.1 only, so it can reach Crow and your local models. It opens no network ports to other machines." + "en": "The phone runner uses host networking, bound to 127.0.0.1 only, so it can reach Crow and your local models. It opens no network ports to other machines.", + "es": "El ejecutor del teléfono usa la red del host, vinculado solo a 127.0.0.1, para poder llegar a Crow y a tus modelos locales. No abre puertos de red hacia otras máquinas." }, "ports": [ 3065 @@ -4807,7 +4822,12 @@ { "id": "plex", "name": "Plex", - "version": "1.0.1", + "version": "1.0.2", + "privileged": true, + "install_consent_messages": { + "en": "Crow does not start a Plex server at install: it runs only if you start it from Extensions. If you do, the self-hosted Plex server uses host networking (Plex's own recommendation) so devices on your local network can discover it. It listens on every network interface of this machine (port 32400 and Plex's discovery ports) and mounts your media folder read-write. Until you claim it with your Plex account (PLEX_CLAIM_TOKEN), the first person who opens it can claim it.", + "es": "Crow no inicia un servidor Plex al instalar: solo se ejecuta si lo inicias desde Extensiones. Si lo haces, el servidor Plex autoalojado usa la red del host (lo que recomienda Plex) para que los dispositivos de tu red local puedan descubrirlo. Escucha en todas las interfaces de red de esta máquina (el puerto 32400 y los puertos de descubrimiento de Plex) y monta tu carpeta de medios con lectura y escritura. Hasta que lo reclames con tu cuenta de Plex (PLEX_CLAIM_TOKEN), la primera persona que lo abra puede reclamarlo." + }, "description": "Connect to Plex Media Server — search library, browse collections, control playback, view active sessions", "type": "mcp-server", "author": "Crow", @@ -5338,7 +5358,7 @@ "id": "rookery", "name": "Open Science Reviewer", "origin": "community", - "version": "0.1.1", + "version": "0.1.2", "description": "Self-hosted OpenScience reviewer + experiment-audit workspace assembler — audit a report's numeric claims against its registered evidence, on your own local model", "type": "bundle", "author": "kh0pper", @@ -5395,7 +5415,7 @@ { "name": "ROOKERY_WORKSPACES_DIR", "description": "Host directory where audit workspaces are assembled (shared by the reviewer and the assemble tool)", - "default": "~/.crow/data/rookery/workspaces", + "default": "~/.crow/data/rookery-workspaces", "required": false }, { @@ -5509,7 +5529,7 @@ { "id": "sdxl", "name": "SDXL Background Generator", - "version": "1.0.0", + "version": "1.0.1", "description": "AI-generated backgrounds for the companion avatar using SDXL Turbo. Requires NVIDIA GPU.", "type": "bundle", "author": "Crow", @@ -5788,7 +5808,12 @@ { "id": "tailscale", "name": "Tailscale", - "version": "1.0.0", + "version": "1.0.1", + "privileged": true, + "install_consent_messages": { + "en": "The Tailscale container joins this machine to your tailnet. It uses host networking, the NET_ADMIN capability and the /dev/net/tun device, so it creates a network interface and changes this machine's routing and firewall rules. Do not install it on a machine that already runs Tailscale.", + "es": "El contenedor de Tailscale une esta máquina a tu tailnet. Usa la red del host, la capacidad NET_ADMIN y el dispositivo /dev/net/tun, así que crea una interfaz de red y cambia las rutas y las reglas de firewall de esta máquina. No lo instales en una máquina que ya ejecuta Tailscale." + }, "description": "Private network access — reach your Crow from any device without exposing it to the public internet", "type": "bundle", "author": "Crow", @@ -6185,7 +6210,7 @@ { "id": "vllm", "name": "vLLM", - "version": "1.0.0", + "version": "1.0.1", "description": "GPU-accelerated local LLM inference server — OpenAI-compatible endpoint backed by vLLM's continuous batching and PagedAttention. Recommended classroom engine for Maker Lab: the Phase 0 Ollama benchmark hit unusable p95 latencies at N=25 concurrent learners; vLLM is architecturally the right shape for that scale.", "type": "bundle", "author": "Crow", diff --git a/servers/gateway/compose-guard.js b/servers/gateway/compose-guard.js new file mode 100644 index 000000000..e67a41a4b --- /dev/null +++ b/servers/gateway/compose-guard.js @@ -0,0 +1,456 @@ +/** + * The install security check, on the compose model Docker Compose itself applies. + * + * The text check (validateComposeFile in routes/bundles.js) reads docker-compose.yml as + * written, which leaves a gap between what it reads and what Compose runs: YAML anchors + * and merge keys, flow/JSON style, interpolated `${VAR}` host paths, override files, + * COMPOSE_FILE in .env, symlinks inside the bundle, top-level volumes with a bind + * `device`. This module closes the gap by asking Compose for the model it will apply + * (`docker compose config --format json`, run in the bundle dir with the same env as + * `up`) and checking THAT, then resolving every host path through symlinks. + * + * Fail-closed: if Compose cannot produce a model, the bundle is refused. A model it + * cannot reason about (unknown volume shape) is refused too. + * + * Every gateway path that runs `compose up` calls checkBundleCompose first: install + * (single and collection: both go through runInstallJob), Start/recreate, and the + * shared-storage re-apply. + */ +import { existsSync, readFileSync, realpathSync, lstatSync, readlinkSync, statSync } from "node:fs"; +import { join, resolve as resolvePath, dirname, basename, sep, isAbsolute } from "node:path"; +import { homedir } from "node:os"; + +// A value Compose sees for a `${VAR:?}` the install left blank (a collection member, +// finished later with Configure): relative, so it resolves inside the bundle dir. +export const UNSET_SENTINEL = "./crow-unset-value"; + +/** realpath of p, or of its nearest existing ancestor + the rest (a path Docker would create). */ +export function resolveHostPath(p, depth = 0) { + let cur = resolvePath(p); + const rest = []; + for (;;) { + try { + const real = realpathSync(cur); + return rest.length ? join(real, ...rest.reverse()) : real; + } catch { + // A dangling symlink still decides where Docker creates the path: follow it. + try { + if (depth < 40 && lstatSync(cur).isSymbolicLink()) { + const target = resolvePath(dirname(cur), readlinkSync(cur)); + return resolveHostPath(rest.length ? join(target, ...rest.reverse()) : target, depth + 1); + } + } catch { /* not there at all */ } + const parent = dirname(cur); + if (parent === cur) return resolvePath(p); + rest.push(basename(cur)); + cur = parent; + } + } +} + +const under = (p, root) => p === root || p.startsWith(root.endsWith(sep) ? root : root + sep); + +/** + * @param {object} ctx + * @param {string} ctx.crowHome + * @param {string} [ctx.dataDir] the instance's data dir (crow.db lives here) + * @param {string} [ctx.appRoot] the gateway checkout (its .env holds gateway secrets) + * @param {string} [ctx.home] + */ +export function sensitiveRoots(ctx) { + const home = ctx.home || homedir(); + const real = (p) => resolveHostPath(p); + const crowHome = real(ctx.crowHome); + const dataDirs = [...new Set([ctx.dataDir, join(ctx.crowHome, "data"), join(home, ".crow", "data")].filter(Boolean).map(real))]; + const sys = ["/etc", "/root", "/boot", "/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32", "/opt", "/var", "/run", "/dev", "/snap", "/proc", "/sys"]; + return { + crowHome, + dataDirs, + home: real(home), + homesRoot: real(dirname(home)), + exact: new Set([real("/"), real(home), real(dirname(home)), ...(ctx.appRoot ? [real(ctx.appRoot)] : [])]), + trees: [ + ...sys.map((d) => ({ root: real(d), why: d, hostInfo: d === "/proc" || d === "/sys" })), + ...(ctx.appRoot ? [{ root: real(ctx.appRoot), why: "the gateway's code and .env" }] : []), + // Every folder on the gateway's PATH: a writable one is code the host runs. + ...[...new Set(String(ctx.path ?? process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")))] + .map((d) => ({ root: real(d), why: `a folder on PATH (${d})` })), + ], + dockerSockets: new Set(["/var/run/docker.sock", "/run/docker.sock", real("/var/run/docker.sock"), real("/run/docker.sock")]), + localtime: real("/etc/localtime"), + }; +} + +// ---- the allowlist ------------------------------------------------------------------- +// Every key Compose can emit for a service / top-level item is listed here with how it is +// treated. A key that is not listed refuses the bundle (fail-closed): new Compose +// features have to be read and classified before a bundle may use them. +// "ok" inert for host access (resources, metadata, container-internal settings) +// "priv" any non-default value needs privileged + consent (never for community) +// fn validated by its function below +const SAFE_CAPS = new Set(["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL", "SETGID", "SETUID", "SETPCAP", "NET_BIND_SERVICE", "SYS_CHROOT", "MKNOD", "AUDIT_WRITE", "SETFCAP", "SYS_NICE", "SYS_RESOURCE", "IPC_LOCK"]); +const SERVICE_OK = new Set([ + "image", "command", "entrypoint", "environment", "container_name", "hostname", "domainname", "ports", "expose", + "restart", "healthcheck", "depends_on", "labels", "annotations", "working_dir", "user", "init", "mem_limit", + "mem_reservation", "memswap_limit", "mem_swappiness", "cpus", "cpu_shares", "cpu_quota", "cpu_period", "cpuset", + "cpu_count", "cpu_percent", "cpu_rt_runtime", "cpu_rt_period", "shm_size", "stop_signal", "stop_grace_period", + "tty", "stdin_open", "read_only", "tmpfs", "ulimits", "extra_hosts", "dns", "dns_search", "dns_opt", "pull_policy", + "platform", "profiles", "attach", "cap_drop", "logging", "pids_limit", "blkio_config", "mac_address", "scale", + "secrets", "configs", "links", "group_add", "stop_timeout", "networks", "gpus", "deploy", "runtime", "sysctls", + "oom_score_adj", "privileged", "cap_add", "security_opt", "network_mode", "pid", "ipc", "uts", "userns_mode", + "cgroup", "devices", "volumes", "env_file", "build", "label_file", "volumes_from", +]); +const SERVICE_PRIV = new Set(["cgroup_parent", "device_cgroup_rules", "external_links", "isolation", "storage_opt", + "credential_spec", "oom_kill_disable", "use_api_socket", "post_start", "pre_stop", "develop", "models", "provider"]); +const TOP_OK = new Set(["name", "services", "networks", "volumes", "secrets", "configs"]); +// Top-level entries of a bundle folder that the gateway reads or runs. +const BUNDLE_CONTROL = new Set(["manifest.json", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", + "docker-compose.override.yml", "docker-compose.override.yaml", "compose.override.yml", "compose.override.yaml", + ".env", ".env.example", "package.json", "package-lock.json", "node_modules", "server", "panel", "scripts", "ops", "skills", "public", "bin"]); +// The only "" names a bundle owns beside itself (a fixed set, so id "rook" +// never reaches bundle "rook-x"'s folder). No bundle id may end in one (tested). +export const OWN_SUFFIXES = ["-downloads", "-workspaces", "-data", "-cache"]; +// Names Crow's own data uses directly under the data folder. +export const RESERVED_DATA_NAMES = new Set(["backups", "peers", "identity", "instance-id", "instance-sync", "legacy", "projects", + "models", "media", "kb-media", "tasks", "keys", "keychain", "secrets", "nostr", "sync", "storage", "uploads", "cache", "tmp", "logs", + "orchestrator-refcounts", "ntfy-push", "ntfy-push-status", "workspace-tools", "wayfinder-tile-cache", "glasses-photos"]); +// Names Crow itself uses directly under CROW_HOME: no bundle id may claim them as its own. +export const RESERVED_HOME_NAMES = new Set(["data", "secrets", "bundles", "panels", "skills", "media", "backups", "env", "logs", + "p4", "pi-bots", "eval", "blog", "bin", "tmp", "cache", "config", "android-keystore", "keychain", "identity", "peer", + "mcp", "models", "board", "phone-token", "artifacts-sidecar", "browser-sessions", "instances", "installed", "live2d-models"]); + +/** + * Check a Compose model (the parsed `docker compose config --format json` output). + * @returns {{valid: true} | {valid: false, reason: string}} + */ +export function checkComposeModel(model, { bundleDir, manifest, consentVerified = false, ctx }) { + const isPrivilegedAllowed = !!(manifest?.privileged && consentVerified && manifest?.origin !== "community"); + const isConsentBundle = !!(manifest?.consent_required && consentVerified && manifest?.origin !== "community"); + const roots = sensitiveRoots(ctx); + const bundleReal = resolveHostPath(bundleDir); + const ownId = /^[a-z0-9][a-z0-9-]*$/.test(String(manifest?.id || "")) ? manifest.id : null; + const refuse = (reason) => ({ valid: false, reason }); + const needPriv = (what) => (isPrivilegedAllowed ? null : refuse(`Compose file uses ${what} but manifest does not declare 'privileged: true' (or consent was not verified)`)); + const isObj = (v) => v && typeof v === "object" && !Array.isArray(v); + if (!isObj(model) || !isObj(model.services)) return refuse("Docker Compose produced no services model"); + for (const k of Object.keys(model)) { + if (!TOP_OK.has(k) && !k.startsWith("x-")) return refuse(`top-level key '${k}' is not one the install check understands`); + } + + // Host-path policy. Inside Crow's home and data folder it is an ALLOWLIST: the bundle's + // own folder, CROW_HOME/ or -*, /, CROW_HOME/media (library defaults), + // and, read-only with privileged consent, crow.db and the extensions folder (companion). + // Elsewhere: system folders, the gateway checkout, home dot-folders and ~/bin are + // refused, and so is any folder that CONTAINS one of these. + const checkOne = (p, { readOnly, what }) => { + if (roots.dockerSockets.has(p)) { + if (!readOnly) return refuse("Read-write Docker socket mounts are not permitted"); + if (!isConsentBundle && !isPrivilegedAllowed) return refuse("Docker socket mount requires manifest 'consent_required: true' and verified consent"); + return null; + } + if (roots.exact.has(p)) return refuse(`${what}: mounts a sensitive host path (${p === "/" ? "/" : "a top-level folder"})`); + if ((p === "/etc/localtime" || p === roots.localtime) && readOnly) return null; + const zones = [roots.crowHome, ...roots.dataDirs]; + if (zones.some((z) => under(z, p) && z !== p)) return refuse(`${what}: mounts a folder that contains Crow's home or data folder`); + for (const d of roots.dataDirs) { + if (!under(p, d)) continue; + if (readOnly && isPrivilegedAllowed && p === join(d, "crow.db")) return null; + // / or /-* — a folder (or not created yet), never a name Crow's + // own data uses (backups, peers, identity…) and never an existing file. + const first = p === d ? "" : p.slice(d.length + 1).split(sep)[0]; + if (ownId && !RESERVED_DATA_NAMES.has(ownId) && (first === ownId || OWN_SUFFIXES.some((x) => first === `${ownId}${x}`)) && !RESERVED_DATA_NAMES.has(first)) { + let isFile = false; + try { isFile = !statSync(join(d, first)).isDirectory(); } catch { /* not there yet */ } + if (!isFile) return null; + } + return refuse(`${what}: mounts a path in Crow's data folder outside its own /${ownId || ""}`); + } + if (under(p, roots.crowHome)) { + const rel = p.slice(roots.crowHome.length + 1); + const first = rel.split(sep)[0]; + if (under(p, bundleReal)) { + // The bundle's own folder, but never the folder itself or its control files read-write: + // the gateway reads the manifest, compose file and .env, and runs server/ code, so a + // container that could rewrite them could grant itself privileges at the next Start. + const top = p.slice(bundleReal.length + 1).split(sep)[0]; + if (!readOnly && (p === bundleReal || BUNDLE_CONTROL.has(top) || (ctx.shipped && ctx.shipped.has(top)))) { + return refuse(`${what}: mounts the bundle's own control files read-write`); + } + return null; + } + // CROW_HOME/ (a folder, or not created yet) and CROW_HOME/-downloads. Never a + // name Crow itself uses there, and never an existing file (tokens, registries). + if (ownId && !RESERVED_HOME_NAMES.has(ownId) && (first === ownId || OWN_SUFFIXES.some((x) => first === `${ownId}${x}`))) { + const top = join(roots.crowHome, first); + let isFile = false; + try { isFile = !statSync(top).isDirectory(); } catch { /* not there yet */ } + if (!isFile) return null; + } + if (first === "media" && rel !== "media") return null; + if (readOnly && isPrivilegedAllowed && p === join(roots.crowHome, "bundles")) return null; + return refuse(`${what}: mounts a path in Crow's home that is not this bundle's own`); + } + // Home folders (this user's and others'): dot-folders and ~/bin hold shells, keys, + // autostart and user services. + if (under(p, roots.homesRoot) && p !== roots.homesRoot) { + const parts = p.slice(roots.homesRoot.length + 1).split(sep); + if (parts.length === 1) return refuse(`${what}: mounts a whole home folder`); + if (parts[1].startsWith(".") || parts[1] === "bin") return refuse(`${what}: mounts a home folder's ${parts[1]}`); + } + for (const t of roots.trees) { + if (!(under(p, t.root) || under(t.root, p))) continue; + if (t.hostInfo && readOnly && under(p, t.root) && (isConsentBundle || isPrivilegedAllowed)) continue; + return refuse(`${what}: mounts a sensitive host path (${t.why})`); + } + // A git working tree (another Crow checkout, lab scripts that timers run): code. Walk up + // from the path to the filesystem root looking for .git, and refuse a folder holding one. + for (let d = p; ; d = dirname(d)) { + if (existsSync(join(d, ".git"))) return refuse(`${what}: mounts a git checkout (code something on this host may run)`); + if (dirname(d) === d) break; + } + return null; + }; + /** Both the path as written and where it really leads (symlinks, dangling ones too). */ + const checkHostPath = (src, { readOnly, what }) => { + if (typeof src !== "string" || src === "") return refuse(`${what}: a host path Compose could not resolve`); + if (!isAbsolute(src)) return refuse(`${what}: a host path that is not absolute after Compose resolved it (${src.slice(0, 80)})`); + const lex = resolvePath(src); + const real = resolveHostPath(src); + return checkOne(lex, { readOnly, what }) || (real !== lex ? checkOne(real, { readOnly, what }) : null); + }; + const insideBundle = (src, what, { allowOutside = false } = {}) => { + if (typeof src !== "string" || src === "") return refuse(`${what}: a path Compose could not resolve`); + if (!isAbsolute(src)) return refuse(`${what}: a path that is not absolute after Compose resolved it`); + const p = resolveHostPath(src); + if (under(p, bundleReal) || allowOutside) return null; + return refuse(`${what}: reads a host path outside the bundle`); + }; + const checkKeys = (obj, allowed, what) => { + if (!isObj(obj)) return refuse(`${what}: not a mapping`); + for (const k of Object.keys(obj)) if (!allowed.includes(k)) return refuse(`${what}: key '${k}' is not one the install check understands`); + return null; + }; + + // A bind source inside another READ-WRITE bind of this project: the container can swap it + // for a symlink after this check, and Docker follows it at the next (re)start, which + // nothing re-checks (restart: unless-stopped). Refused. + { + const binds = []; + for (const s of Object.values(model.services || {})) { + for (const v of (s && s.volumes) || []) if (v && v.type === "bind" && typeof v.source === "string" && isAbsolute(v.source)) binds.push({ p: resolveHostPath(v.source), rw: v.read_only !== true }); + } + for (const a of binds) { + for (const b of binds) { + if (a !== b && a.rw && a.p !== b.p && under(b.p, a.p)) return refuse(`a bind mount sits inside another read-write bind mount (${b.p.slice(-60)})`); + } + } + } + + const networkNames = new Set(Object.keys(model.networks || {})); + for (const [name, s] of Object.entries(model.services)) { + const tag = `service ${name}`; + if (!isObj(s)) return refuse(`${tag}: not a mapping`); + let r; + for (const k of Object.keys(s)) { + if (SERVICE_PRIV.has(k)) { if ((r = needPriv(`${k}`))) return r; continue; } + if (!SERVICE_OK.has(k)) return refuse(`${tag}: key '${k}' is not one the install check understands`); + } + if (s.privileged !== undefined && s.privileged !== false && (r = needPriv("privileged mode"))) return r; + for (const c of s.cap_add || []) { + const cap = String(c).toUpperCase().replace(/^CAP_/, ""); + if (!SAFE_CAPS.has(cap) && (r = needPriv(`a capability outside the safe set (${cap})`))) return r; + } + const nm = s.network_mode === undefined ? "" : String(s.network_mode); + if (nm && !(nm === "bridge" || nm === "none" || nm === "default" || nm.startsWith("service:")) && (r = needPriv(`network_mode ${nm}`))) return r; + for (const k of ["pid", "ipc", "uts", "userns_mode", "cgroup"]) { + if (s[k] === undefined) continue; + const v = String(s[k]); + const ok = (k === "ipc" && (v === "private" || v === "shareable" || v.startsWith("service:"))) || (k === "cgroup" && v === "private") || (k === "pid" && v.startsWith("service:")); + if (!ok && (r = needPriv(`${k}: ${v}`))) return r; + } + for (const o of s.security_opt || []) { + if (!/^no-new-privileges(?:[:=]true)?$/.test(String(o)) && (r = needPriv(`security_opt ${o}`))) return r; + } + if (s.volumes_from !== undefined && (r = needPriv("volumes_from"))) return r; + if (s.sysctls !== undefined && Object.keys(s.sysctls || {}).length && (r = needPriv("sysctls"))) return r; + // A moderately negative score (workspace's DB: -500) only changes who the OOM killer + // picks; below -500, down to OOM-immune -1000, needs consent. + if (s.oom_score_adj !== undefined && !(Number(s.oom_score_adj) >= -500) && (r = needPriv("oom_score_adj below -500"))) return r; + if (s.runtime !== undefined && !["runc", "nvidia"].includes(String(s.runtime)) && (r = needPriv(`runtime ${s.runtime}`))) return r; + for (const d of s.devices || []) { + const raw = typeof d === "string" ? d.split(":")[0] : d?.source; + // Normalized AND resolved: /dev/dri/../nvme0n1 is a raw disk. + const isGpu = (x) => /^\/dev\/(dri(\/[A-Za-z0-9_.-]+)?|kfd|nvidia[a-z0-9-]*)$/.test(x) && !x.split("/").includes(".."); + const gpu = typeof raw === "string" && isAbsolute(raw) && isGpu(resolvePath(raw)) && isGpu(resolveHostPath(raw)); + const src = raw; + if (!gpu && (r = needPriv(`a host device (${String(src || "?").slice(0, 60)})`))) return r; + } + if (s.deploy !== undefined) { + if ((r = checkKeys(s.deploy, ["resources", "restart_policy", "replicas", "mode", "labels", "update_config", "rollback_config", "placement", "endpoint_mode"], `${tag} deploy`))) return r; + for (const dev of s.deploy.resources?.reservations?.devices || []) { + const caps = (dev.capabilities || []).flat().map(String); + if (!(caps.length && caps.every((c) => ["gpu", "compute", "utility", "graphics", "video", "display"].includes(c)))) { + if ((r = needPriv("a device reservation other than a GPU"))) return r; + } + } + } + for (const n of Object.keys(isObj(s.networks) ? s.networks : {})) { + if (!networkNames.has(n)) return refuse(`${tag}: network '${n}' is not declared`); + } + for (const v of s.volumes || []) { + if (!isObj(v)) return refuse(`${tag}: a volume Compose did not normalize`); + if ((r = checkKeys(v, ["type", "source", "target", "read_only", "consistency", "bind", "volume", "tmpfs", "image"], `${tag} volume`))) return r; + if (v.type === "bind") { + if (v.bind !== undefined) { + if ((r = checkKeys(v.bind, ["propagation", "create_host_path", "selinux"], `${tag} bind`))) return r; + if (v.bind.propagation !== undefined && !["private", "rprivate"].includes(v.bind.propagation) && (r = needPriv(`bind propagation ${v.bind.propagation}`))) return r; + if (v.bind.selinux !== undefined && (r = needPriv("an SELinux relabel of a host path"))) return r; + } + if ((r = checkHostPath(v.source, { readOnly: v.read_only === true, what: tag }))) return r; + } else if (v.type === "volume" || v.type === "tmpfs" || v.type === "image") { + if (v.type === "volume" && v.source !== undefined && !Object.hasOwn(model.volumes || {}, v.source)) return refuse(`${tag}: volume '${v.source}' is not declared`); + } else { + return refuse(`${tag}: volume type '${v.type}' is not one the install check understands`); + } + } + for (const e of s.env_file || []) { + const path = typeof e === "string" ? e : e?.path; + if ((r = insideBundle(path, `${tag} env_file`, { allowOutside: manifest?.inference === true }))) return r; + } + for (const lf of [].concat(s.label_file || [])) if ((r = insideBundle(lf, `${tag} label_file`))) return r; + if (s.build !== undefined) { + const b = typeof s.build === "string" ? { context: s.build } : s.build; + if ((r = checkKeys(b, ["context", "dockerfile", "dockerfile_inline", "args", "target", "labels", "cache_from", "cache_to", "no_cache", "pull", "platforms", "tags", "shm_size", "additional_contexts", "network"], `${tag} build`))) return r; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(String(b.context))) return refuse(`${tag}: a remote build context`); + if ((r = insideBundle(b.context, `${tag} build context`))) return r; + if (b.network !== undefined && b.network !== "default" && b.network !== "none" && (r = needPriv(`build network ${b.network}`))) return r; + for (const c of Object.values(b.additional_contexts || {})) { + if ((r = insideBundle(c, `${tag} build additional_contexts`))) return r; + } + } + } + // The project name decides which volumes and networks Compose reuses: a bundle may not + // take another bundle's (name: crow-workspace) to reach its data. It must be , + // crow-, or one of those plus "-" (a second instance). Volume and network + // names must stay inside the project. + const project = String(model.name || ""); + if (ownId && project) { + const okProject = [ownId, `crow-${ownId}`].some((b) => project === b || project.startsWith(`${b}-`)); + if (!okProject) return refuse(`the compose project name '${project.slice(0, 60)}' is not this bundle's (${ownId})`); + } + const inProject = (n) => !project || n.startsWith(`${project}_`) || n.startsWith(`${project}-`) || n === project; + for (const [name, n] of Object.entries(model.networks || {})) { + let r; + if (n && !n.external && n.name && !inProject(String(n.name))) return refuse(`network '${name}' is named outside this project (${String(n.name).slice(0, 60)})`); + if ((r = checkKeys(n, ["name", "driver", "driver_opts", "external", "internal", "attachable", "ipam", "labels", "enable_ipv6", "enable_ipv4"], `network ${name}`))) return r; + const real = String(n.name || name); + // An external network joins another project's network; only Crow's shared federation + // network is expected. + if (n.external && real !== "crow-federation") { if ((r = needPriv(`the external network '${real}'`))) return r; } + if (n.driver !== undefined && n.driver !== "bridge" && (r = needPriv(`network driver ${n.driver}`))) return r; + } + for (const [name, vol] of Object.entries(model.volumes || {})) { + let r; + if ((r = checkKeys(vol, ["name", "driver", "driver_opts", "external", "labels"], `volume ${name}`))) return r; + if (!vol.external && vol.name && !inProject(String(vol.name))) return refuse(`volume '${name}' is named outside this project (${String(vol.name).slice(0, 60)}): another bundle's data`); + // An external volume is another project's data (another extension's database…). + if (vol.external && (r = needPriv(`the external volume '${vol.name || name}'`))) return r; + if (vol.driver !== undefined && vol.driver !== "local" && (r = needPriv(`volume driver ${vol.driver}`))) return r; + const dev = vol.driver_opts?.device; + if (dev !== undefined) { + const o = String(vol.driver_opts.o || ""); + const type = String(vol.driver_opts.type || ""); + const remote = /^(nfs4?|cifs|smb3?)$/.test(type) && !/(?:^|,)r?bind(?:,|$)/.test(o); + if (!remote) { + if (typeof dev !== "string" || !dev.startsWith("/")) return refuse(`volume ${name}: a bind device that is not an absolute path`); + if ((r = checkHostPath(dev, { readOnly: /(?:^|,)ro(?:,|$)/.test(o), what: `volume ${name}` }))) return r; + } + } + } + for (const [kind, items] of [["secrets", model.secrets], ["configs", model.configs]]) { + for (const [name, it] of Object.entries(items || {})) { + let r; + if ((r = checkKeys(it, ["name", "file", "content", "external", "labels"], `${kind} ${name}`))) return r; + // (`environment:` is refused by omission: it copies a variable from the env Compose + // runs with — the gateway's — into the container.) + if (it.file !== undefined && (r = insideBundle(it.file, `${kind} ${name}`))) return r; + } + } + return { valid: true }; +} + +/** True when the model reaches the host's network: host / another container's network + * mode, a network on the host driver or named host, or a LAN-level driver. */ +export function usesHostNetwork(model) { + for (const s of Object.values(model?.services || {})) { + const nm = String(s?.network_mode || ""); + if (nm === "host" || nm.startsWith("container:")) return true; + } + for (const [name, n] of Object.entries(model?.networks || {})) { + const real = String(n?.name || name); + if (["host", "macvlan", "ipvlan"].includes(String(n?.driver || "")) || (n?.external && real === "host")) return true; + } + return false; +} + +/** `${VAR:?…}` / `${VAR?…}` names in a compose text (the ones Compose refuses to leave blank). */ +export function requiredComposeVars(text) { + const out = new Set(); + const re = /\$\{([A-Za-z_][A-Za-z0-9_]*):?\?/g; + let m; + while ((m = re.exec(String(text || ""))) !== null) out.add(m[1]); + return [...out]; +} + +function parseDotEnv(path) { + const out = {}; + try { + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (m) out[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, "$2"); + } + } catch { /* none */ } + return out; +} + +/** + * The model `docker compose up` would apply in bundleDir. `runConfig(args, {cwd, env})` + * runs `docker compose ` and resolves {stdout}. A `${VAR:?}` left blank (collection + * installs leave required keys for "Finish setup") gets UNSET_SENTINEL so the rest of the + * model can still be checked; Start re-runs this check once the real value is set. + * @returns {Promise<{model: object} | {error: string}>} + */ +export async function loadComposeModel(bundleDir, { env, runConfig }) { + const args = ["config", "--format", "json", "--no-env-resolution"]; + // Every profile: a service behind an inactive profile is still in the file, and + // `compose --profile x up` (a hook, an operator) would start it. + const attempt = async (e) => { + const { stdout } = await runConfig(args, { cwd: bundleDir, env: { ...e, COMPOSE_PROFILES: "*" } }); + return JSON.parse(String(stdout)); + }; + try { + const model = await attempt(env); + // Compose always names the project; a model without one is not what up would apply. + if (!model || typeof model.name !== "string" || !model.name) return { error: "Docker Compose produced a model without a project name" }; + return { model }; + } catch (first) { + let text = ""; + for (const f of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) { + try { if (existsSync(join(bundleDir, f))) text += readFileSync(join(bundleDir, f), "utf8") + "\n"; } catch { /* skip */ } + } + const dot = parseDotEnv(join(bundleDir, ".env")); + const filled = { ...env }; + let changed = false; + for (const k of requiredComposeVars(text)) { + if (!filled[k] && !dot[k]) { filled[k] = UNSET_SENTINEL; changed = true; } + } + if (changed) { + try { + const model = await attempt(filled); + if (model && typeof model.name === "string" && model.name) return { model }; + } catch { /* fall through */ } + } + const msg = String(first?.stderr || first?.message || "unknown error").split("\n").map((l) => l.trim()).filter(Boolean).slice(-2).join(" "); + return { error: `Docker Compose could not read the bundle's compose file (${msg.slice(0, 300)})` }; + } +} diff --git a/servers/gateway/routes/bundles.js b/servers/gateway/routes/bundles.js index 6fb373a65..a58b2e906 100644 --- a/servers/gateway/routes/bundles.js +++ b/servers/gateway/routes/bundles.js @@ -23,7 +23,7 @@ import bus from "../../shared/event-bus.js"; import { isSupervised } from "../../shared/supervisor.js"; import { createDbClient } from "../../db.js"; import { execFile } from "node:child_process"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, cpSync, rmSync, copyFileSync, unlinkSync, symlinkSync, statSync, realpathSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, cpSync, rmSync, copyFileSync, unlinkSync, symlinkSync, statSync, realpathSync, readdirSync } from "node:fs"; import { join, dirname, resolve as resolvePath, relative as relativePath, isAbsolute, basename } from "node:path"; import { randomBytes, createHash } from "node:crypto"; import { checkInstall as checkHardwareGate } from "../hardware-gate.js"; @@ -58,6 +58,8 @@ import { envValueProblem, encodeEnvValue, formatEnvLines, updateEnvText, pathEnv import { sanitizeKeychainRequest, recordKeychainForInstall, markBundleKeychainRemoved } from "../keychain/install-hooks.js"; import { precreateDirs, runPostInstall, hookEnv, spawnGroup, pullTimeoutMs, resolveComposeProject, classifyProjectOwners } from "../bundle-lifecycle.js"; import { provisionNtfy, deprovisionNtfy, AUTOWIRE_KIND } from "../push/ntfy-provision.js"; +import { checkComposeModel, loadComposeModel, usesHostNetwork } from "../compose-guard.js"; +import { resolveDataDir } from "../../db.js"; import { readStoredNtfyConfig } from "../push/ntfy-config.js"; import { planGeneratedEnv, stripGeneratedKeys, parseEnvText, writePrivateFile, gatewayExcludedKeys, gatewayGeneratedKeys, retainGeneratedForReinstall, envPatternViolation, breachedValueViolation } from "../bundle-env-secrets.js"; @@ -542,7 +544,7 @@ function composeExpandEnv(destDir) { } catch { /* no .env → process env only */ } // compose: shell env wins over .env; composeEnv() adds the CROW_HOME every // compose run gets (the prod gateway unit may not export it). - return composeEnv({ ...fileVars, ...process.env }); + return { ...fileVars, ...composeEnv() }; } /** @@ -1146,9 +1148,20 @@ async function getComposeCmd() { * (`${CROW_HOME:?…}`), so an unset value fails the run outright. Resolving it here * means every compose run names its instance explicitly rather than depending on * whichever unit happened to export it. + * + * Only an ALLOWLIST of the gateway's own variables goes through: Compose interpolates + * `${ANY_VAR}` from this env, so passing all of it would let a compose file copy any + * gateway secret (API keys, tokens) into its container. A bundle's own settings come + * from its .env, which Compose reads itself. */ +const COMPOSE_ENV_KEYS = new Set(["PATH", "HOME", "USER", "LOGNAME", "LANG", "LANGUAGE", "TZ", "TMPDIR", "XDG_RUNTIME_DIR", "CROW_GATEWAY_PORT", "CROW_GATEWAY_URL", "CROW_DB_PATH", "CROW_DATA_DIR"]); export function composeEnv(base = process.env) { - return { ...base, CROW_HOME }; + const env = {}; + for (const [k, v] of Object.entries(base || {})) { + if (COMPOSE_ENV_KEYS.has(k) || /^LC_[A-Z]+$/.test(k) || (/^DOCKER_[A-Z_]+$/.test(k) && k !== "DOCKER_DEFAULT_PLATFORM")) env[k] = v; + } + env.CROW_HOME = CROW_HOME; + return env; } // Test-only: replace every `docker compose` invocation (pull/up/down/...) with a @@ -1416,19 +1429,46 @@ function isValidBundleId(id) { return /^[a-z0-9][a-z0-9-]*$/.test(id) && id.length <= 64; } +// The one /etc mount allowed: /etc/localtime, read-only, same path on both sides, short +// syntax, nothing after `:ro` but whitespace + a comment. It is public data on any host. +const TZ_MOUNT_LINE = /^( *)- +(["']?)\/etc\/localtime:\/etc\/localtime:ro\2(?:[ \t]+#.*)?$/; + +/** Text with the allowed timezone mounts removed — only when the NEXT non-blank line is not + * more indented (a more-indented line would continue the scalar: `:ro` + `\n ,rw`). */ +function withoutTzMounts(content) { + const lines = content.split("\n"); + return lines.filter((line, i) => { + const m = line.match(TZ_MOUNT_LINE); + if (!m) return true; + const next = lines.slice(i + 1).find((l) => l.trim() !== ""); + if (next === undefined) return false; + const ind = next.match(/^[ \t]*/)[0]; + return ind.includes("\t") || ind.length > m[1].length; // keep (so the /etc rule refuses it) if it continues + }).join("\n"); +} + +const HOST_NETWORK = /network_mode["']?\s*:\s*["']?host/i; + +// Compose picks these up instead of, or on top of, docker-compose.yml; the check reads +// only docker-compose.yml, so a bundle shipping one would run unchecked config. +const OTHER_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", + "docker-compose.override.yml", "docker-compose.override.yaml", "compose.override.yml", "compose.override.yaml"]; + /** - * Validate a docker-compose.yml for security issues. + * Inspect a compose file for security concerns (PR 0). * Rejects: sensitive host mounts, privileged mode, dangerous capabilities. - * Returns { valid: true } or { valid: false, reason: string }. - */ -/** - * Inspect a compose file for security concerns. + * + * A TEXT check, not a YAML parse: it catches the written forms our bundles and reviewed + * add-ons use, and errs toward refusing. Known gaps (flow/JSON-style keys, interpolated + * host paths, `..` paths, `secrets: file:`) are listed in the private engineering notes; + * tests/bundle-compose-gate.test.js runs it over every first-party bundle. * * @param {string} composePath - path to docker-compose.yml * @param {string} bundleId * @param {object} [opts] * @param {object} [opts.manifest] - bundle manifest; if it has `privileged: true` - * AND `opts.consentVerified` is true, NET_ADMIN/host-network/privileged are allowed. + * AND `opts.consentVerified` is true, NET_ADMIN/host-network/privileged are allowed + * (never for an `origin: "community"` bundle). * Read-only Docker socket mounts are allowed when manifest declares `consent_required: true` * (and consent has been verified) — bundles like netdata/dozzle need this. * @param {boolean} [opts.consentVerified=false] - true when the install request supplied a @@ -1437,21 +1477,87 @@ function isValidBundleId(id) { */ function validateComposeFile(composePath, bundleId, opts = {}) { const { manifest, consentVerified = false } = opts; - const isPrivilegedAllowed = !!(manifest?.privileged && consentVerified); + const isPrivilegedAllowed = !!(manifest?.privileged && consentVerified && manifest?.origin !== "community"); const isConsentBundle = !!(manifest?.consent_required && consentVerified); try { const content = readFileSync(composePath, "utf8"); - // Sensitive host paths that should never be mounted (regardless of consent) + const other = OTHER_COMPOSE_FILES.filter((f) => existsSync(join(dirname(composePath), f))); + if (other.length) { + return { valid: false, reason: `Bundle ships another compose file (${other.join(", ")}) that Docker Compose would use; only docker-compose.yml is allowed` }; + } + + // Compose reads COMPOSE_* from the project's .env (COMPOSE_FILE swaps the very file + // checked here); .env.example becomes .env when an install sends no values. + for (const f of [".env", ".env.example"]) { + let t = ""; + try { t = readFileSync(join(dirname(composePath), f), "utf8"); } catch { continue; } + // COMPOSE_PROJECT_NAME is allowed (a co-hosted instance names its own project; the + // model check holds it to /crow-[-suffix]). Any other COMPOSE_* is refused. + if (/^\s*(?:export\s+)?COMPOSE_(?!PROJECT_NAME\s*[=:])[A-Za-z0-9_]*\s*[=:]/m.test(t)) { + return { valid: false, reason: `Bundle ${f} sets a COMPOSE_* variable, which would change what Docker Compose runs` }; + } + } + + // Config pulled in from other files is never read by this check. + if (/^["']?include["']?\s*:/m.test(content) || /extends["']?\s*:\s*\{[^}]*\bfile\s*:/.test(content) || /^(\s*)extends\s*:\s*\n(?:\1\s+.*\n)*?\1\s+file\s*:/m.test(content)) { + return { valid: false, reason: "Compose file uses include: or extends: file:, which pull in config this check cannot see" }; + } + + // Host files outside the bundle can't be read in: env_file, secrets/configs `file:`, + // and the build context must be relative paths inside the bundle (no /, ~, ..). + // Model bundles (inference: true; installed only by the Model Catalog, never here) + // read the shared GPU env file under ~/.crow/env. + const outside = (v) => /^["']?(?:\/|~|\$\{?HOME\b)/.test(v) || /(?:^|\/)\.\.(?:\/|["']?\s*$)/.test(v.replace(/\s+#.*$/, "").trim()); + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + let m = l.match(/^(\s*)(?:-\s+)?(env_file|file|context|path)\s*:\s*(.*)$/) || l.match(/^(\s*)()build\s*:\s*(\S.*)$/); + if (m && m[3] && outside(m[3].trim())) { + if (!(m[2] === "env_file" && manifest?.inference === true)) { + return { valid: false, reason: `Compose file reads a host path outside the bundle (${(m[2] || "build").trim()})` }; + } + } + m = l.match(/^(\s*)env_file\s*:\s*$/); + if (m) { + for (let j = i + 1; j < lines.length; j++) { + const item = lines[j].match(/^(\s*)-\s+(?:path\s*:\s*)?(.*)$/); + if (!item || item[1].length <= m[1].length) break; + if (outside(item[2].trim()) && manifest?.inference !== true) { + return { valid: false, reason: "Compose file reads a host path outside the bundle (env_file)" }; + } + } + } + } + + // Sensitive host paths that should never be mounted (regardless of consent). + // Short syntax (`- /etc:/x`) and long syntax (`source: /etc`). + const mountText = withoutTzMounts(content); const sensitivePatterns = [ /^\s*-\s+["']?\/:/m, // root mount / /^\s*-\s+["']?\/etc[/:]/m, // /etc /^\s*-\s+["']?~?\/?\.ssh[/:]/m, // ~/.ssh /^\s*-\s+["']?~?\/?\.crow\/data[/:]/m, // ~/.crow/data + /^\s*(?:-\s+)?source:\s*["']?\/(?:["'\s]|$)/m, // source: / + /^\s*(?:-\s+)?source:\s*["']?\/etc(?:[/"'\s]|$)/m, // source: /etc + /^\s*(?:-\s+)?source:\s*["']?~?\/?\.ssh(?:[/"'\s]|$)/m, // source: ~/.ssh + /^\s*(?:-\s+)?source:\s*["']?~?\/?\.crow\/data(?:[/"'\s]|$)/m, // source: ~/.crow/data + /^\s*(?:-\s+)?source:\s*["']?(?:[^"'\s]*\/)?\.\.(?:[/"'\s]|$)/m, // source: ../ ]; + // A short-syntax source with a `..` step climbs out of the bundle dir (../../data is + // the Crow data dir). ${...} is masked so its `:-` is not taken as the separator. + for (const l of mountText.split("\n")) { + const item = l.match(/^\s*-\s+["']?(.*)$/); + if (!item) continue; + const host = item[1].replace(/\$\{[^}]*\}/g, "X").split(":")[0]; + if (/(?:^|\/)\.\.(?:\/|["']?\s*$)/.test(host)) { + return { valid: false, reason: "Compose file mounts a host path outside the bundle (a .. step)" }; + } + } + for (const pattern of sensitivePatterns) { - if (pattern.test(content)) { + if (pattern.test(mountText)) { return { valid: false, reason: `Compose file mounts a sensitive host path (matched: ${pattern.source})` }; } } @@ -1467,19 +1573,23 @@ function validateComposeFile(composePath, bundleId, opts = {}) { return { valid: false, reason: "Docker socket mount requires manifest 'consent_required: true' and verified consent" }; } - // Privileged mode - if (/^\s*privileged:\s*true/m.test(content) && !isPrivilegedAllowed) { + // Privileged mode (any case, quoted key or value) + if (/^\s*["']?privileged["']?\s*:\s*["']?true\b/im.test(content) && !isPrivilegedAllowed) { return { valid: false, reason: "Compose file uses privileged mode but manifest does not declare 'privileged: true' (or consent was not verified)" }; } - // Dangerous capabilities (NET_ADMIN etc.) — gated by manifest.privileged + consent - if (/NET_ADMIN|SYS_ADMIN|SYS_PTRACE|SYS_RAWIO|NET_RAW/m.test(content) && !isPrivilegedAllowed) { + // Dangerous capabilities (NET_ADMIN etc., or cap_add ALL) — gated by manifest.privileged + consent + const allCaps = /cap_add["']?\s*:\s*\[[^\]]*\bALL\b/i.test(content) || /^\s*-\s*["']?(?:CAP_)?ALL["']?\s*$/m.test(content); + if ((/NET_ADMIN|SYS_ADMIN|SYS_PTRACE|SYS_RAWIO|NET_RAW/m.test(content) || allCaps) && !isPrivilegedAllowed) { return { valid: false, reason: "Compose file requests dangerous capabilities (NET_ADMIN, SYS_ADMIN, NET_RAW, etc.) but manifest does not declare 'privileged: true' (or consent was not verified)" }; } - // Host networking — gated by manifest.privileged + consent - if (/network_mode:\s*host/i.test(content) && !isPrivilegedAllowed) { - return { valid: false, reason: "Compose file uses host networking but manifest does not declare 'privileged: true' (or consent was not verified)" }; + // Host networking, another container's network, or the host's PID/user namespace — + // gated by manifest.privileged + consent (`network_mode: service:x` stays in this file). + const sharedNs = /network_mode["']?\s*:\s*["']?container:/i.test(content) + || /^\s*["']?(?:pid|userns_mode)["']?\s*:\s*["']?(?:host|container:)/im.test(content); + if ((HOST_NETWORK.test(content) || sharedNs) && !isPrivilegedAllowed) { + return { valid: false, reason: "Compose file uses host networking (or shares another namespace) but manifest does not declare 'privileged: true' (or consent was not verified)" }; } return { valid: true }; @@ -1488,6 +1598,79 @@ function validateComposeFile(composePath, bundleId, opts = {}) { } } +/** What the owner consented to at install, kept on the installed.json record. */ +function consentRecord(manifest, consentVerified) { + // Always written (also for a bundle that asked for nothing), so a missing record can only + // mean "installed before this was kept", never "a bundle that asked for nothing". + if (!manifestRequiresConsent(manifest)) return { consent: { verified: false, privileged: false, consent_required: false } }; + return { consent: { verified: !!consentVerified, privileged: manifest?.privileged === true, consent_required: manifest?.consent_required === true } }; +} + +/** + * The manifest + consent a later `compose up` (Start, recreate) is checked with. Consent + * is the owner's, given at install and kept on the installed record, and covers only what + * the manifest asked for THEN (a refresh that makes a bundle privileged does not inherit + * it). A peer instance's request never carries it. A record from before this was kept + * (no `consent` field) keeps its installed manifest's flags: it was installed through the + * consent gate of its time. + */ +function startConsent(bundleId, { crossHost = false } = {}) { + // Trust flags never come from the installed copy: its folder can be mounted into the + // bundle's own container. They come from the reviewed repo manifest (privileged, + // consent_required, origin, inference), capped by what the owner consented to. + const installed = getInstalledFirstManifest(bundleId) || {}; + const repo = getManifest(bundleId); + const trust = { + privileged: repo?.privileged === true, + consent_required: repo?.consent_required === true, + origin: repo ? repo.origin : "community", + inference: repo?.inference === true, + id: bundleId, + }; + const manifest = { ...installed, ...trust }; + if (!manifestRequiresConsent(manifest) || crossHost) return { manifest, consentVerified: false }; + const rec = getInstalled().find((i) => i.id === bundleId); + if (!rec) return { manifest, consentVerified: false }; + if (!rec.consent) return { manifest, consentVerified: true }; // installed before consent was kept: the repo flags, as of then + if (rec.consent.verified !== true) return { manifest, consentVerified: false }; + return { + manifest: { ...manifest, privileged: trust.privileged && rec.consent.privileged === true, consent_required: trust.consent_required && rec.consent.consent_required === true }, + consentVerified: true, + }; +} + +export { startConsent as _startConsentForTest, consentRecord as _consentRecordForTest }; + +// Test-only: replace `docker compose config` (the model the shared check reads). +let _composeConfigRunnerForTest = null; +export function _setComposeConfigRunnerForTest(fn) { _composeConfigRunnerForTest = fn || null; } +async function runComposeConfig(args, { cwd, env }) { + if (_composeConfigRunnerForTest) return _composeConfigRunnerForTest(args, { cwd, env }); + const compose = await getComposeCmd(); + return run(compose.cmd, [...compose.prefix, ...args], { cwd, env, timeout: 60_000 }); +} + +/** + * THE install security check, shared by every path that runs `docker compose up` on a + * bundle: install (single + collection, via runInstallJob), Start/recreate, and the + * shared-storage re-apply. `textCheck` (install only) also checks the file as shipped — + * e.g. no extra compose files; at Start an operator's own override is part of the model + * that is checked instead. The model check reads what Compose will actually apply. + */ +export async function checkBundleCompose(bundleDir, { bundleId, manifest, consentVerified = false, textCheck = true } = {}) { + if (textCheck) { + const t = validateComposeFile(join(bundleDir, "docker-compose.yml"), bundleId, { manifest, consentVerified }); + if (!t.valid) return t; + } + const loaded = await loadComposeModel(bundleDir, { env: composeEnv(), runConfig: runComposeConfig }); + if (loaded.error) return { valid: false, reason: loaded.error }; + // Everything the bundle ships (settings-section.js, run.sh, src/ …) is code or config the + // gateway may load: never mountable read-write. + let shipped = null; + try { shipped = new Set(readdirSync(join(APP_BUNDLES, bundleId || basename(bundleDir)))); } catch { /* not in the repo */ } + return checkComposeModel(loaded.model, { bundleDir, manifest, consentVerified, ctx: { crowHome: CROW_HOME, dataDir: resolveDataDir(), appRoot: APP_ROOT, shipped } }); +} + // --- PR 0: Consent token helpers (server-validated install consent) --- /** @@ -1570,12 +1753,12 @@ function summarizePrivileges(manifest, composePath) { if (manifest?.privileged) { try { const content = readFileSync(composePath, "utf8"); - if (/network_mode:\s*host/i.test(content)) caps.push("host networking"); + if (HOST_NETWORK.test(content)) caps.push("host networking"); if (/NET_ADMIN/.test(content)) caps.push("NET_ADMIN (modify firewall)"); if (/NET_RAW/.test(content)) caps.push("NET_RAW (raw sockets)"); if (/SYS_ADMIN/.test(content)) caps.push("SYS_ADMIN (system administration)"); if (/SYS_PTRACE/.test(content)) caps.push("SYS_PTRACE (process tracing)"); - if (/^\s*privileged:\s*true/m.test(content)) caps.push("full privileged mode"); + if (/^\s*["']?privileged["']?\s*:\s*["']?true\b/im.test(content)) caps.push("full privileged mode"); } catch { /* ignore */ } } if (manifest?.consent_required && !manifest?.privileged) { @@ -1761,7 +1944,7 @@ function getAiProviderConfig(bundleId, envVars) { * (Task 6) maps it to a per-member skip/fail entry. Semantics are identical to * the pre-refactor inline chain in POST /bundles/api/install (order matters: * id → source exists → already-installed → dependencies → hardware → GPU → - * docker-at-point-of-use → consent → hosted-host-networking refusal). + * docker-at-point-of-use → hosted-host-networking refusal → consent). * * @returns {Promise< * { ok: true, manifest: object, installed: Array, consentVerified: boolean, hardwareWarning?: object } @@ -1880,6 +2063,9 @@ export function findInvalidEnv(envVars) { if (!envVars || typeof envVars !== "object") return null; for (const [k, v] of Object.entries(envVars)) { if (!/^[A-Z_][A-Z0-9_]*$/.test(k)) return { key: k, why: "is not a valid environment variable name" }; + // A bundle .env is also Docker Compose's own settings file: COMPOSE_FILE would swap the + // file the install security check read, and Start never re-checks it. + if (/^(?:COMPOSE|DOCKER)_/.test(k) && k !== "COMPOSE_PROJECT_NAME") return { key: k, why: "controls Docker itself, so an extension can't set it" }; if (v !== undefined && v !== null) { const problem = envValueProblem(v); if (problem) return { key: k, why: problem }; @@ -2036,6 +2222,29 @@ export async function validateInstall(bundleId, { envVars = {}, consentToken = n } } + // Block bundles with network_mode: host on managed hosting (security risk on shared + // infrastructure). Checked BEFORE consent, so a host-network bundle (they are all + // privileged) is refused without spending the user's consent token first. + if (process.env.CROW_HOSTED) { + const composePath = join(sourceDir, "docker-compose.yml"); + if (existsSync(composePath)) { + const composeContent = readFileSync(composePath, "utf8"); + // The text check, then the model Compose resolves (catches ${N:-host}, LAN-level + // network drivers). A model that can't be read is refused here (fail-closed). + let hostNet = HOST_NETWORK.test(composeContent); + if (!hostNet) { + const loaded = await loadComposeModel(sourceDir, { env: composeEnv(), runConfig: runComposeConfig }); + hostNet = !!loaded.error || usesHostNetwork(loaded.model); + } + if (hostNet) { + return { + ok: false, status: 403, code: "hosted_forbidden", + error: "This bundle requires host networking and is not available on managed hosting.", + }; + } + } + } + // PR 0: consent token check — required for privileged or consent_required bundles let consentVerified = false; if (manifestRequiresConsent(manifest)) { @@ -2061,20 +2270,6 @@ export async function validateInstall(bundleId, { envVars = {}, consentToken = n } } - // Block bundles with network_mode: host on managed hosting (security risk on shared infrastructure) - if (process.env.CROW_HOSTED) { - const composePath = join(sourceDir, "docker-compose.yml"); - if (existsSync(composePath)) { - const composeContent = readFileSync(composePath, "utf8"); - if (/network_mode:\s*host/i.test(composeContent)) { - return { - ok: false, status: 403, code: "hosted_forbidden", - error: "This bundle requires host networking and is not available on managed hosting.", - }; - } - } - } - return { ok: true, manifest, installed, consentVerified, ...(hardwareWarning ? { hardwareWarning } : {}) }; } @@ -2314,10 +2509,7 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, // PR 0: validate compose for ALL bundles (first-party + community). // First-party bundles with privileged/host-network must declare manifest.privileged // and the install request must include a verified consent token. - const validation = validateComposeFile(composePath, bundleId, { - manifest, - consentVerified, - }); + const validation = await checkBundleCompose(destDir, { bundleId, manifest, consentVerified }); if (!validation.valid) { appendLog(job, `Security check failed: ${validation.reason}`); // Clean up copied files @@ -2365,7 +2557,7 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, // "Start" — recorded NOW so a later step throwing can't lose it. const installed2 = getInstalled(); if (!installed2.find((i) => i.id === bundleId)) { - installed2.push({ id: bundleId, type: addonType, version: manifest?.version, installedAt: new Date().toISOString() }); + installed2.push({ id: bundleId, type: addonType, version: manifest?.version, installedAt: new Date().toISOString(), ...consentRecord(manifest, consentVerified) }); saveInstalled(installed2); } composeFailure = `docker compose up failed: ${detail}`; @@ -2569,6 +2761,7 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, type: addonType, version: manifest?.version || "1.0.0", installedAt: new Date().toISOString(), + ...consentRecord(manifest, consentVerified), }); saveInstalled(installedSnapshot); appendLog(job, "Installation tracked"); @@ -2790,6 +2983,22 @@ export default function bundlesRouter() { }); router.use(xhostVerify); + // Owner actions. A signed peer instance may start/stop, read status and Configure + // (which never saves to the keychain for a peer, and whose result Start re-checks + // without consent), never install, mint consent, uninstall or re-apply storage here: + // a peer-minted consent token would let it apply a privileged bundle the owner never saw. + const OWNER_ONLY = new Set(["/bundles/api/install", "/bundles/api/install-set", "/bundles/api/uninstall", "/bundles/api/repin"]); + router.use((req, res, next) => { + if (!req.crossHostAuth) return next(); + // Express matches routes case-insensitively and ignores a trailing slash: compare + // the same normalized form, so /INSTALL or /install/ can't slip past. + const p = String(req.path || "").toLowerCase().replace(/\/+$/, "").replace(/\/{2,}/g, "/"); + if (OWNER_ONLY.has(p) || p.startsWith("/bundles/api/consent-challenge/") || p.startsWith("/bundles/api/shared-storage/apply/")) { + return res.status(403).json({ error: "This is an owner action on this instance; a peer instance can't perform it", code: "owner_only" }); + } + return next(); + }); + // GET /bundles/api/status — List installed bundles with container status router.get("/bundles/api/status", async (req, res) => { const installed = getInstalled(); @@ -3373,6 +3582,12 @@ export default function bundlesRouter() { if (own.refusal) return res.status(409).json({ error: own.refusal, code: "compose_project_foreign" }); if (own.warning) console.warn(`[bundles] ${bundleId} ${action}: ${own.warning}`); if (action === "start") { + // The same check as install, on what Compose will apply NOW (Configure may have + // changed .env; an override may sit next to the file). The installed bundle was + // consented to at install, so a consent bundle keeps its consent here. + const sc = startConsent(bundleId, { crossHost: !!req?.crossHostAuth }); + const gate = await checkBundleCompose(bundleDir, { bundleId, manifest: sc.manifest, consentVerified: sc.consentVerified, textCheck: false }); + if (!gate.valid) return res.status(403).json({ error: `Security check failed: ${gate.reason}`, code: "compose_security_check" }); const content = readFileSync(composePath, "utf8"); const upArgs = /^\s+build:/m.test(content) ? ["up", "-d", "--build"] : ["up", "-d"]; // recreate:true (the Configure dialog's explicit Restart): new env only @@ -3656,6 +3871,10 @@ export default function bundlesRouter() { // Force-recreate so the affected containers pick up the new env. const composePath = join(bundleDir, "docker-compose.yml"); if (existsSync(composePath)) { + // Checked AFTER the injection: the new .env is part of what Compose applies. + const sc = startConsent(bundleId); + const gate = await checkBundleCompose(bundleDir, { bundleId, manifest: sc.manifest, consentVerified: sc.consentVerified, textCheck: false }); + if (!gate.valid) return res.status(403).json({ error: `Security check failed: ${gate.reason}`, code: "compose_security_check" }); await runCompose(["up", "-d", "--force-recreate"], { cwd: bundleDir }); } res.json({ ok: true, version: injected.version, recreated: existsSync(composePath) }); diff --git a/tests/bundle-compose-gate.test.js b/tests/bundle-compose-gate.test.js new file mode 100644 index 000000000..d3e92a745 --- /dev/null +++ b/tests/bundle-compose-gate.test.js @@ -0,0 +1,307 @@ +// tests/bundle-compose-gate.test.js +// +// Our own bundles must pass our own install security check. The check +// (validateComposeFile in servers/gateway/routes/bundles.js) runs on every +// install; eight first-party bundles were refused by it for months because no +// test ran it over the real compose files. This file does, for EVERY tracked +// bundle, plus unit cases for the rules that changed with it: +// - exactly the read-only /etc/localtime mount is allowed from /etc; +// - quoted `network_mode: "host"`, `privileged: True`, `cap_add: [ALL]`, long-syntax +// `source:` mounts and a second compose file are caught; +// - a community-origin bundle never gets privileged powers. +// The check reads compose files as TEXT, comments included: netdata's comment named a +// blocked capability, so the comment was reworded rather than the check weakened. +// It also refuses a first-party port published on every interface. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { _validateComposeFileForTest as validate } from "../servers/gateway/routes/bundles.js"; +import { trackedBundleSet } from "../scripts/build-registry.mjs"; +import { parseComposeHostPorts } from "../servers/gateway/port-inventory.js"; + +const REPO = fileURLToPath(new URL("..", import.meta.url)); + +// Bundles we EXPECT the gate to refuse even with consent. Each entry needs a +// reason a reviewer can check. Keep it empty: a first-party bundle that cannot +// be installed should be fixed or removed, not listed here. +const EXPECTED_REFUSALS = {}; + +// First-party ports deliberately published on every interface. +const ALL_INTERFACE_BINDS = { + caddy: { reason: "public reverse proxy for federated bundles: it must answer 80/443 from the internet", ports: ["80", "443", "443/udp"] }, +}; + +function trackedComposeBundles() { + const tracked = trackedBundleSet(); + assert.ok(tracked && tracked.size > 0, "git ls-files found no tracked bundles"); + return [...tracked] + .filter((id) => existsSync(join(REPO, "bundles", id, "docker-compose.yml"))) + .sort() + .map((id) => ({ + id, + composePath: join(REPO, "bundles", id, "docker-compose.yml"), + manifest: JSON.parse(readFileSync(join(REPO, "bundles", id, "manifest.json"), "utf8")), + })); +} + +const requiresConsent = (m) => m?.privileged === true || m?.consent_required === true; + +test("every tracked first-party compose passes the install security check (with its manifest + consent)", () => { + const bundles = trackedComposeBundles(); + assert.ok(bundles.length >= 60, `expected the full bundle set, got ${bundles.length}`); + const refused = []; + for (const b of bundles) { + const r = validate(b.composePath, b.id, { manifest: b.manifest, consentVerified: true }); + if (r.valid && EXPECTED_REFUSALS[b.id]) refused.push(`${b.id}: listed in EXPECTED_REFUSALS but now passes; remove the entry`); + if (!r.valid && !EXPECTED_REFUSALS[b.id]) refused.push(`${b.id}: ${r.reason}`); + } + assert.deepEqual(refused, [], `install security check refuses first-party bundles:\n ${refused.join("\n ")}`); +}); + +test("a bundle whose compose needs consent declares it, refuses without it, and explains it in English and Spanish", () => { + const problems = []; + for (const b of trackedComposeBundles()) { + if (EXPECTED_REFUSALS[b.id]) continue; + const bare = { ...b.manifest, privileged: false, consent_required: false }; + const needs = !validate(b.composePath, b.id, { manifest: bare, consentVerified: true }).valid; + if (!needs) continue; + if (!requiresConsent(b.manifest)) problems.push(`${b.id}: compose needs consent, manifest declares neither privileged nor consent_required`); + if (validate(b.composePath, b.id, { manifest: b.manifest, consentVerified: false }).valid) problems.push(`${b.id}: passes WITHOUT consent`); + const msgs = b.manifest.install_consent_messages || {}; + if (!(msgs.en || "").trim() || !(msgs.es || "").trim()) problems.push(`${b.id}: install_consent_messages needs en + es`); + } + assert.deepEqual(problems, [], problems.join("\n")); +}); + +// Binds that name an env var with no default are allowed only for model bundles: their +// .env (written by the Model Catalog) supplies the tailnet address. Anything else binds a +// literal address or a ${VAR:-literal} default; unset, a bare ${VAR} binds every interface. +const TEMPLATE_BIND_OK = (m) => m.inference === true; + +/** `ports:` items as the shared parser walks them, to catch entries it would drop. */ +function rawPortItems(text) { + const out = []; + let inPorts = false; + for (const raw of text.split("\n")) { + const line = raw.replace(/#.*$/, ""); + if (/^\s*ports:\s*$/.test(line)) { inPorts = true; continue; } + if (!inPorts) continue; + if (/^\s*-/.test(line)) { out.push(line.trim()); continue; } + if (line.trim()) inPorts = false; + } + return out; +} + +test("no first-party bundle publishes a port on every interface", () => { + const bad = []; + for (const b of trackedComposeBundles()) { + const text = readFileSync(b.composePath, "utf8"); + if (/^\s*ports:\s*\[/m.test(text) || /^\s*-?\s*published:/m.test(text)) { + bad.push(`${b.id}: inline or long-syntax ports; write "- 127.0.0.1:HOST:CONTAINER" so they can be checked`); + } + const parsed = parseComposeHostPorts(text); + const items = rawPortItems(text); + if (parsed.length !== items.length) bad.push(`${b.id}: a port entry the checker cannot read (${items.join(" | ")})`); + const allowed = ALL_INTERFACE_BINDS[b.id]; + for (const p of parsed) { + if (p.bindKind === "loopback" || p.bindKind === "specific") continue; + if (p.bindKind === "template" && TEMPLATE_BIND_OK(b.manifest)) continue; + const hp = `${p.port}${p.proto === "udp" ? "/udp" : ""}`; + if (p.bindKind === "all" && allowed && allowed.ports.includes(hp)) continue; + bad.push(`${b.id}: port ${hp} binds ${p.bindKind === "template" ? `${p.bind} (every interface when unset)` : "every interface"}`); + } + } + assert.deepEqual(bad, [], `ports published on all interfaces:\n ${bad.join("\n ")}`); +}); + +test("the port check reads the shapes our composes use", () => { + const text = [ + "services:", " a:", " ports:", " # comment", + ' - "127.0.0.1:8080:80"', " - 9000:9000", ' - "${X_PORT:-3005}:3005"', + ' - "127.0.0.1:${Y_PORT:-8088}:8080"', ' - "8555:8555/udp"', ' - "${CROW_TAILSCALE_IP}:8003:8000"', + ' - "0.0.0.0:443:443/udp" # HTTP/3', " - 7000", + ].join("\n"); + assert.deepEqual(parseComposeHostPorts(text).map((p) => p.bindKind), ["loopback", "all", "all", "loopback", "all", "template", "all"]); + assert.equal(rawPortItems(text).length, 8, "the bare '- 7000' is seen by the raw walk, so the count check catches the parser dropping it"); +}); + +// ---- gate unit cases ------------------------------------------------------- + +function gate(yaml, opts = {}) { + const dir = mkdtempSync(join(tmpdir(), "crow-gate-")); + try { + const p = join(dir, "docker-compose.yml"); + writeFileSync(p, yaml); + return validate(p, "fx", opts); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} +const svc = (body) => `services:\n app:\n image: busybox\n${body}`; +const PRIV = { manifest: { privileged: true }, consentVerified: true }; + +test("comments still count: the check reads the file as text", () => { + assert.equal(gate(svc(" # cap_add: [SYS_PTRACE]\n")).valid, false); + assert.equal(gate(svc(" cap_add: [NET_RAW] # trailing\n")).valid, false); + assert.equal(gate(svc(" privileged: true\n")).valid, false); + for (const v of ["True", "TRUE", '"true"']) assert.equal(gate(svc(` privileged: ${v}\n`)).valid, false, v); + assert.equal(gate(svc(' "privileged": true\n')).valid, false, "quoted key"); +}); + +test("cap_add ALL is a dangerous capability; cap_drop ALL is not", () => { + assert.equal(gate(svc(" cap_add: [ALL]\n")).valid, false); + assert.equal(gate(svc(" cap_add:\n - ALL\n")).valid, false); + assert.equal(gate(svc(" cap_add: [ALL]\n"), PRIV).valid, true); + assert.equal(gate(svc(" cap_drop: [ALL]\n")).valid, true); +}); + +test("exactly the read-only /etc/localtime mount is allowed from /etc", () => { + for (const ok of [ + " - /etc/localtime:/etc/localtime:ro\n", + ' - "/etc/localtime:/etc/localtime:ro"\n', + " - '/etc/localtime:/etc/localtime:ro'\n", + " - /etc/localtime:/etc/localtime:ro # trailing comment\n", + " - /etc/localtime:/etc/localtime:ro\n - ./data:/data\n", + " - /etc/localtime:/etc/localtime:ro\n environment:\n - A=1\n", + ]) { + assert.equal(gate(svc(` volumes:\n${ok}`)).valid, true, ok); + } + for (const bad of [ + " - /etc/localtime:/etc/localtime\n", + " - /etc/localtime:/etc/localtime:rw\n", + " - /etc/localtime:/x:ro\n", + " - /etc/timezone:/etc/timezone:ro\n", + " - /etc/passwd:/etc/passwd:ro\n", + " - /etc:/host-etc:ro\n", + " - /etc/localtime/../shadow:/etc/localtime:ro\n", + " - /etc/localtime:/etc/localtime:ro,z\n", + " - /etc/localtime:/etc/localtime:ro#,rw\n", + " - /etc/localtime:/etc/localtime:ro\n ,rw\n", + " - /etc/localtimex:/etc/localtimex:ro\n", + " - /etc/localtime:/etc/localtime:ro\n - /etc/shadow:/s:ro\n", + ]) { + assert.equal(gate(svc(` volumes:\n${bad}`)).valid, false, bad); + } +}); + +test("a second compose file next to docker-compose.yml is refused (Compose would use it unchecked)", () => { + for (const f of ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.override.yml", "compose.override.yaml"]) { + const dir = mkdtempSync(join(tmpdir(), "crow-gate-")); + try { + writeFileSync(join(dir, "docker-compose.yml"), svc("")); + writeFileSync(join(dir, f), svc(" privileged: true\n")); + assert.equal(validate(join(dir, "docker-compose.yml"), "fx", PRIV).valid, false, f); + } finally { rmSync(dir, { recursive: true, force: true }); } + } +}); + +test("a community-origin bundle never gets privileged powers, even with consent", () => { + const community = { manifest: { privileged: true, origin: "community" }, consentVerified: true }; + assert.equal(gate(svc(" network_mode: host\n"), community).valid, false); + assert.equal(gate(svc(" cap_add: [NET_ADMIN]\n"), community).valid, false); + assert.equal(gate(svc(" network_mode: host\n"), PRIV).valid, true); +}); + +test("quoted host networking is caught; privileged + consent still allows it", () => { + for (const v of ['"host"', "'host'", "host"]) { + assert.equal(gate(svc(` network_mode: ${v}\n`)).valid, false, v); + assert.equal(gate(svc(` network_mode: ${v}\n`), PRIV).valid, true, v); + } + assert.equal(gate(svc(" network_mode: bridge\n")).valid, true); +}); + +test("long-syntax volume sources are held to the same sensitive-path rule", () => { + for (const src of ["/etc", '"/etc/shadow"', "/", "'/'", "~/.ssh", "~/.ssh/id_rsa", "~/.crow/data"]) { + const y = svc(` volumes:\n - type: bind\n source: ${src}\n target: /x\n`); + assert.equal(gate(y).valid, false, src); + assert.equal(gate(y, PRIV).valid, false, `${src} is refused even with privileged + consent`); + } + assert.equal(gate(svc(" volumes:\n - type: bind\n source: ./data\n target: /x\n")).valid, true); + assert.equal(gate(svc(" volumes:\n - type: bind\n source: /etc/localtime\n target: /etc/localtime\n read_only: true\n")).valid, false, + "long-syntax timezone mounts are not allowlisted (short syntax only)"); +}); + +test("the four host-network bundles are privileged and explain it in English and Spanish", () => { + for (const id of ["companion", "coturn", "plex", "tailscale"]) { + const m = JSON.parse(readFileSync(join(REPO, "bundles", id, "manifest.json"), "utf8")); + assert.equal(m.privileged, true, `${id} must be privileged (host networking)`); + assert.ok(/host network/i.test(m.install_consent_messages?.en || ""), `${id}: English consent text must name host networking`); + assert.ok((m.install_consent_messages?.es || "").length > 40, `${id}: Spanish consent text missing`); + } +}); + +// ---- review round 2: ways to make Compose run config the check never read ---------- + +function gateDir(files, opts = {}) { + const dir = mkdtempSync(join(tmpdir(), "crow-gate-")); + try { + for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text); + return validate(join(dir, "docker-compose.yml"), "fx", opts); + } finally { rmSync(dir, { recursive: true, force: true }); } +} + +test("COMPOSE_* settings in the bundle's .env/.env.example (Compose reads them: COMPOSE_FILE swaps the file) are refused", () => { + for (const name of [".env", ".env.example"]) { + for (const line of ["COMPOSE_FILE=evil.yml", " COMPOSE_FILE = evil.yml", "export COMPOSE_FILE=evil.yml", "COMPOSE_PATH_SEPARATOR=;", "COMPOSE_PROFILES=x"]) { + assert.equal(gateDir({ "docker-compose.yml": svc(""), [name]: `A=1\n${line}\n` }, PRIV).valid, false, `${name}: ${line}`); + } + } + assert.equal(gateDir({ "docker-compose.yml": svc(""), ".env.example": "# COMPOSE_FILE is not used\nA=1\n" }).valid, true); +}); + +test("include: and extends: file: pull in config the check never read", () => { + assert.equal(gate(`include:\n - other.yml\n${svc("")}`, PRIV).valid, false); + assert.equal(gate(`include: [other.yml]\n${svc("")}`, PRIV).valid, false); + assert.equal(gate(svc(" extends:\n file: other.yml\n service: x\n"), PRIV).valid, false); + assert.equal(gate(svc(" extends: { file: other.yml, service: x }\n"), PRIV).valid, false); + assert.equal(gate(svc(" extends:\n service: base\n")).valid, true, "extends within the same file is fine"); +}); + +test("host files outside the bundle can't be read in through env_file, secrets/configs file:, or the build context", () => { + for (const body of [ + " env_file: /home/u/.crow/.env\n", + " env_file:\n - ../../.env\n", + " env_file:\n - ~/.crow/.env\n", + " env_file:\n - path: /etc/shadow\n", + " build:\n context: /\n", + " build: /etc\n", + " build:\n context: ../..\n", + " build: ~/\n", + ]) { + assert.equal(gate(svc(body), PRIV).valid, false, body); + } + assert.equal(gate(`${svc("")}secrets:\n s:\n file: /etc/shadow\n`, PRIV).valid, false); + assert.equal(gate(`${svc("")}configs:\n c:\n file: ../../data/crow.db\n`, PRIV).valid, false); + for (const ok of [" env_file: .env\n", " env_file:\n - ./app.env\n", " build: .\n", " build:\n context: ./renderer\n"]) { + assert.equal(gate(svc(ok)).valid, true, ok); + } + assert.equal(gate(`${svc("")}secrets:\n s:\n file: ./secret.txt\n`).valid, true); +}); + +test("a mount source that climbs out of the bundle dir with .. is refused (../../data is the Crow data dir)", () => { + for (const v of [" - ../../data:/d\n", " - ./../../data:/d:ro\n", ' - "../x:/x"\n', " - ${X:-.}/../../data:/d\n"]) { + assert.equal(gate(svc(` volumes:\n${v}`), PRIV).valid, false, v); + } + assert.equal(gate(svc(" volumes:\n - type: bind\n source: ../../data\n target: /d\n"), PRIV).valid, false); + assert.equal(gate(svc(" volumes:\n - ./data..old:/d\n")).valid, true, "a name containing .. is not a parent step"); +}); + +test("sharing another container's network, or the host's PID/user namespace, needs privileged + consent", () => { + for (const body of [" network_mode: container:crow-browser\n", ' network_mode: "container:abc"\n', " pid: host\n", ' pid: "host"\n', " pid: container:x\n", " userns_mode: host\n"]) { + assert.equal(gate(svc(body)).valid, false, body); + assert.equal(gate(svc(body), PRIV).valid, true, body); + } + assert.equal(gate(svc(" network_mode: service:db\n")).valid, true, "service: is a service in the same, checked file"); +}); + +test("install and Configure refuse COMPOSE_* / DOCKER_* keys (they would steer Docker Compose itself)", async () => { + const { findInvalidEnv } = await import("../servers/gateway/routes/bundles.js"); + for (const k of ["COMPOSE_FILE", "COMPOSE_PROFILES", "DOCKER_HOST"]) { + assert.equal(findInvalidEnv({ [k]: "x" })?.key, k, k); + } + assert.equal(findInvalidEnv({ MY_COMPOSE_FILE: "x", A: "1" }), null); + assert.equal(findInvalidEnv({ COMPOSE_PROJECT_NAME: "browser-r4" }), null, "a co-hosted instance names its project (the model check holds the name to the bundle)"); +}); diff --git a/tests/bundle-lifecycle-hooks.test.js b/tests/bundle-lifecycle-hooks.test.js index 8f748ef74..a3b68c408 100644 --- a/tests/bundle-lifecycle-hooks.test.js +++ b/tests/bundle-lifecycle-hooks.test.js @@ -200,8 +200,10 @@ test("install: an unsafe precreate entry refuses the install and removes the cop }); test("REVIEW FOCUS 6 — a second instance cannot adopt another instance's compose project", async () => { + // (The project name is one this bundle may use — crow-- — so the OWNERSHIP guard + // is what refuses; a foreign project name is refused earlier by the install security check.) const id = "hk-owned"; - const m = fixture(id, { postInstall: { script: "ops/bootstrap.sh" } }, "name: crow-shared\nservices:\n app:\n image: busybox:1.36\n"); + const m = fixture(id, { postInstall: { script: "ops/bootstrap.sh" } }, "name: crow-hk-owned-shared\nservices:\n app:\n image: busybox:1.36\n"); const composeCalls = []; B._setComposeRunnerForTest(async (args) => { composeCalls.push(args[0]); return { stdout: "", stderr: "" }; }); // The household's install lives in ANOTHER Crow home that really lists this bundle. @@ -217,15 +219,15 @@ test("REVIEW FOCUS 6 — a second instance cannot adopt another instance's compo // install refused, nothing started, copied files removed const { out } = await install(id, m); assert.equal(out.ok, false); - assert.equal(out.reason, `This extension's containers (compose project "crow-shared") belong to another Crow install on this host (${otherHome}/bundles/${id}): manage them from there.`); + assert.equal(out.reason, `This extension's containers (compose project "crow-hk-owned-shared") belong to another Crow install on this host (${otherHome}/bundles/${id}): manage them from there.`); assert.deepEqual(composeCalls, []); - assert.ok(dockerCalls.some((a) => a.includes("label=com.docker.compose.project=crow-shared"))); + assert.ok(dockerCalls.some((a) => a.includes("label=com.docker.compose.project=crow-hk-owned-shared"))); assert.equal(existsSync(join(CROW_HOME, "bundles", id)), false); // a copy that IS installed here (older install) cannot start/stop/down the foreign project const dir = join(CROW_HOME, "bundles", id); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "manifest.json"), JSON.stringify(m)); - writeFileSync(join(dir, "docker-compose.yml"), "name: crow-shared\nservices:\n app:\n image: busybox:1.36\n"); + writeFileSync(join(dir, "docker-compose.yml"), "name: crow-hk-owned-shared\nservices:\n app:\n image: busybox:1.36\n"); writeFileSync(join(CROW_HOME, "installed.json"), JSON.stringify([...installedIds().map((i) => ({ id: i })), { id, type: "bundle", version: "0.1.0" }])); const app = express(); app.use(express.json()); app.use(B.default()); const server = app.listen(0, "127.0.0.1"); await new Promise((r) => server.once("listening", r)); diff --git a/tests/bundles-compose-env.test.js b/tests/bundles-compose-env.test.js index 637d0b9b0..5f2c22831 100644 --- a/tests/bundles-compose-env.test.js +++ b/tests/bundles-compose-env.test.js @@ -11,10 +11,14 @@ test("compose always receives a CROW_HOME, even when the gateway's env has none" assert.ok(env.CROW_HOME && env.CROW_HOME.length > 0); }); -test("compose env preserves the caller's other variables", () => { - const env = composeEnv({ PATH: "/usr/bin", SOME_TOKEN: "abc" }); +test("compose env passes only an allowlist of the gateway's variables (a compose file could interpolate any of them into its container)", () => { + const env = composeEnv({ PATH: "/usr/bin", HOME: "/h", LC_ALL: "C", DOCKER_HOST: "unix:///x", SOME_TOKEN: "abc", ANTHROPIC_API_KEY: "k", CROW_LOCAL_MCP_TOKEN: "t", CROW_DB_PATH: "/d" }); assert.equal(env.PATH, "/usr/bin"); - assert.equal(env.SOME_TOKEN, "abc"); + assert.equal(env.HOME, "/h"); + assert.equal(env.LC_ALL, "C"); + assert.equal(env.DOCKER_HOST, "unix:///x"); + assert.equal(env.CROW_DB_PATH, "/d", "instance paths (not secrets) pass, so co-hosted instances mount their own DB"); + for (const k of ["SOME_TOKEN", "ANTHROPIC_API_KEY", "CROW_LOCAL_MCP_TOKEN"]) assert.equal(env[k], undefined, k); }); test("an explicit CROW_HOME from the caller does not win over the resolved one", () => { diff --git a/tests/bundles-validate-install.test.js b/tests/bundles-validate-install.test.js index 92d74fdf8..2615851b9 100644 --- a/tests/bundles-validate-install.test.js +++ b/tests/bundles-validate-install.test.js @@ -85,10 +85,12 @@ test("consent bundle with an invalid/bogus token → 403 consent_invalid", async }); test("host-networking bundle refused on managed hosting → 403 hosted_forbidden", async () => { - // 'plex' has `network_mode: host` in its docker-compose.yml and declares - // neither consent_required nor privileged, so with CROW_HOSTED set it must - // be refused by the hosted-networking gate (the branch the plan's code - // block missed entirely — nothing else in this suite pins it). + // 'plex' has `network_mode: host` in its docker-compose.yml, so with + // CROW_HOSTED set it must be refused by the hosted-networking gate (the + // branch the plan's code block missed entirely — nothing else in this suite + // pins it). plex is privileged now, and no consent token is passed: the + // hosted refusal comes BEFORE the consent check, so no token is spent on a + // bundle this host can never run. const prevHosted = process.env.CROW_HOSTED; process.env.CROW_HOSTED = "1"; try { diff --git a/tests/compose-guard.test.js b/tests/compose-guard.test.js new file mode 100644 index 000000000..4ac8277cc --- /dev/null +++ b/tests/compose-guard.test.js @@ -0,0 +1,611 @@ +/** + * The install security check on the compose MODEL Docker Compose applies + * (servers/gateway/compose-guard.js), and that every path that runs `compose up` + * goes through the same check (checkBundleCompose): install (single + collection + * share runInstallJob) and Start. Docker-backed cases skip when `docker compose` + * is not on this machine (CI has it). + */ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync, existsSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync, execFile } from "node:child_process"; + +process.env.CROW_HOME = mkdtempSync(join(tmpdir(), "crow-cguard-home-")); +process.env.CROW_DATA_DIR = mkdtempSync(join(tmpdir(), "crow-cguard-data-")); +process.env.CROW_AUTO_UPDATE = "0"; +process.env.CROW_DISABLE_HEALTH_MONITOR = "1"; +process.env.CROW_DISABLE_INSTANCE_SYNC = "1"; +process.env.CROW_DISABLE_NOSTR = "1"; +const CROW_HOME = process.env.CROW_HOME; +const DATA = process.env.CROW_DATA_DIR; +const REPO = fileURLToPath(new URL("..", import.meta.url)); + +const G = await import("../servers/gateway/compose-guard.js"); +const B = await import("../servers/gateway/routes/bundles.js"); +const { trackedBundleSet } = await import("../scripts/build-registry.mjs"); + +const FIXTURES = mkdtempSync(join(tmpdir(), "crow-cguard-app-")); +B._setAppBundlesForTest(FIXTURES); +const upCalls = []; +B._setComposeRunnerForTest(async (args) => { upCalls.push(args); return { stdout: "", stderr: "" }; }); +B._setDockerRunnerForTest(async () => ({ stdout: "", stderr: "" })); +after(() => { + B._setComposeRunnerForTest(null); + B._setDockerRunnerForTest(null); + for (const d of [CROW_HOME, DATA, FIXTURES]) rmSync(d, { recursive: true, force: true }); +}); + +let HAVE_COMPOSE = false; +try { execFileSync("docker", ["compose", "version"], { stdio: "pipe" }); HAVE_COMPOSE = true; } catch { /* skip docker cases */ } +const dockerTest = (name, fn) => test(name, { skip: HAVE_COMPOSE ? false : "docker compose not available" }, fn); + +const CTX = { crowHome: CROW_HOME, dataDir: DATA, appRoot: REPO }; +const PRIV = { privileged: true }; +const svc = (extra = {}) => ({ services: { app: { image: "busybox", ...extra } } }); +const bundleDir = () => { const d = join(CROW_HOME, "bundles", `fx-${Math.random().toString(36).slice(2, 8)}`); mkdirSync(d, { recursive: true }); return d; }; +const check = (model, { manifest = {}, consent = false, dir = bundleDir() } = {}) => G.checkComposeModel(model, { bundleDir: dir, manifest, consentVerified: consent, ctx: CTX }); +const bind = (source, read_only = false) => ({ type: "bind", source, target: "/x", ...(read_only ? { read_only } : {}) }); + +// ---- the model check (no docker) --------------------------------------------------- + +test("model: privileged, dangerous caps (any case, CAP_ prefix), shared namespaces, unconfined, volumes_from need privileged + consent", () => { + for (const extra of [ + { privileged: true }, { cap_add: ["net_admin"] }, { cap_add: ["CAP_SYS_ADMIN"] }, { cap_add: ["ALL"] }, + { network_mode: "host" }, { network_mode: "container:abc" }, { pid: "host" }, { pid: "container:x" }, + { userns_mode: "host" }, { uts: "host" }, { cgroup: "host" }, + { security_opt: ["apparmor=unconfined"] }, { security_opt: ["seccomp:unconfined"] }, { security_opt: ["label=disable"] }, + { volumes_from: ["other"] }, + ]) { + assert.equal(check(svc(extra)).valid, false, JSON.stringify(extra)); + assert.equal(check(svc(extra), { manifest: PRIV, consent: true }).valid, true, JSON.stringify(extra)); + assert.equal(check(svc(extra), { manifest: { ...PRIV, origin: "community" }, consent: true }).valid, false, `community: ${JSON.stringify(extra)}`); + } + assert.equal(check(svc({ network_mode: "service:db", cap_add: ["CHOWN"], security_opt: ["no-new-privileges:true"] })).valid, true); +}); + +test("model: sensitive host paths are refused even with privileged consent; their parents too", () => { + const home = homedir(); + for (const src of ["/", "/etc", "/etc/shadow", join(home, ".ssh"), join(home, ".ssh", "id_rsa"), home, CROW_HOME, + join(CROW_HOME, "secrets"), join(CROW_HOME, "secrets", "keychain.key"), DATA, join(DATA, "other.db"), + join(CROW_HOME, ".env"), join(CROW_HOME, "mcp-addons.json"), join(REPO, ".env"), REPO, "/home"]) { + assert.equal(check(svc({ volumes: [bind(src, true)] }), { manifest: PRIV, consent: true }).valid, false, src); + } + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, "workspace", "db"))] }), { manifest: { id: "workspace" } }).valid, true, "the bundle's own CROW_HOME/ folder"); + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, "workspace", "db"))] }), { manifest: { id: "other" } }).valid, false, "another bundle's CROW_HOME folder"); +}); + +test("model: the narrow exceptions — /etc/localtime read-only; crow.db and the extensions folder read-only only with privileged consent", () => { + assert.equal(check(svc({ volumes: [bind("/etc/localtime", true)] })).valid, true); + assert.equal(check(svc({ volumes: [bind("/etc/localtime")] })).valid, false, "read-write localtime"); + const db = join(DATA, "crow.db"); + assert.equal(check(svc({ volumes: [bind(db, true)] }), { manifest: PRIV, consent: true }).valid, true); + assert.equal(check(svc({ volumes: [bind(db)] }), { manifest: PRIV, consent: true }).valid, false, "read-write crow.db"); + assert.equal(check(svc({ volumes: [bind(db, true)] })).valid, false, "no consent"); + const bundles = join(CROW_HOME, "bundles"); + assert.equal(check(svc({ volumes: [bind(bundles, true)] }), { manifest: PRIV, consent: true }).valid, true); + assert.equal(check(svc({ volumes: [bind(bundles, true)] })).valid, false); + const dir = bundleDir(); + assert.equal(check(svc({ volumes: [bind(join(dir, "data"))] }), { dir }).valid, true, "the bundle's own folder"); + assert.equal(check(svc({ volumes: [bind(join(bundles, "other", ".env"), true)] }), { dir }).valid, false, "another bundle's folder"); +}); + +test("model: a symlink inside the bundle that points at a sensitive path is resolved and refused", () => { + const dir = bundleDir(); + symlinkSync("/etc", join(dir, "cfg")); + symlinkSync(join(CROW_HOME, "secrets"), join(dir, "s")); + assert.equal(check(svc({ volumes: [bind(join(dir, "cfg"), true)] }), { dir }).valid, false); + assert.equal(check(svc({ volumes: [bind(join(dir, "s", "x"))] }), { dir }).valid, false, "a not-yet-existing path under the link"); + assert.equal(check(svc({ build: { context: join(dir, "cfg") } }), { dir }).valid, false, "a build context through the link"); +}); + +test("model: Docker socket read-only needs consent; read-write never", () => { + const sock = { volumes: [bind("/var/run/docker.sock", true)] }; + assert.equal(check(svc(sock)).valid, false); + assert.equal(check(svc(sock), { manifest: { consent_required: true }, consent: true }).valid, true); + assert.equal(check(svc({ volumes: [bind("/var/run/docker.sock")] }), { manifest: PRIV, consent: true }).valid, false); +}); + +test("model: env_file, build context, secrets/configs files and bind-device volumes stay inside the bundle", () => { + const dir = bundleDir(); + const out = "/etc/hostname"; + assert.equal(check(svc({ env_file: [{ path: join(CROW_HOME, ".env") }] }), { dir }).valid, false); + assert.equal(check(svc({ env_file: [{ path: join(dir, "app.env") }] }), { dir }).valid, true); + assert.equal(check(svc({ env_file: [{ path: "/opt/gpu.env" }] }), { dir, manifest: { inference: true } }).valid, true, "model bundles' shared GPU env file"); + assert.equal(check(svc({ build: { context: "/" } }), { dir }).valid, false); + assert.equal(check(svc({ build: { context: dir, additional_contexts: { x: "/etc" } } }), { dir }).valid, false); + assert.equal(check(svc({ build: { context: "https://example.invalid/repo.git" } }), { dir }).valid, false, "remote build contexts are refused (not modelled)"); + assert.equal(check({ ...svc(), secrets: { s: { file: out } } }, { dir }).valid, false); + assert.equal(check({ ...svc(), configs: { c: { file: join(dir, "c.conf") } } }, { dir }).valid, true); + assert.equal(check({ ...svc(), volumes: { v: { driver_opts: { type: "none", o: "bind", device: "/etc" } } } }, { dir, manifest: PRIV, consent: true }).valid, false); +}); + +test("model: fail-closed on what it can't reason about", () => { + assert.equal(check({}).valid, false); + assert.equal(check(svc({ volumes: [{ type: "cluster", source: "x", target: "/x" }] })).valid, false); + assert.equal(check(svc({ volumes: [bind("relative/path")] })).valid, false); + assert.equal(check(svc({ volumes: ["/etc:/x"] })).valid, false, "a short-syntax string Compose did not normalize"); +}); + +// ---- what Compose really applies (docker compose config) ---------------------------- + +function project(files) { + const dir = bundleDir(); + for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text); + return dir; +} +const gateDir = (dir, opts = {}) => B.checkBundleCompose(dir, { bundleId: "fx", manifest: {}, consentVerified: false, ...opts }); +const C = (body) => `services:\n app:\n image: busybox\n${body}`; + +dockerTest("parser differentials: YAML merge keys, anchors, flow style and interpolated paths are seen as Compose applies them", async () => { + for (const files of [ + { "docker-compose.yml": `x-p: &p\n privileged: true\nservices:\n app:\n image: busybox\n <<: *p\n` }, + { "docker-compose.yml": `x-v: &v ["/etc:/host-etc"]\nservices:\n app:\n image: busybox\n volumes: *v\n` }, + { "docker-compose.yml": `{"services": {"app": {"image": "busybox", "network_mode": "host"}}}\n` }, + { "docker-compose.yml": C(" volumes:\n - ${MEDIA}:/m\n"), ".env": "MEDIA=/etc\n" }, + { "docker-compose.yml": C(" volumes:\n - ${DB:-~/.ssh}:/k:ro\n") }, + ]) { + const r = await gateDir(project(files), { textCheck: false }); + assert.equal(r.valid, false, JSON.stringify(files)); + } +}); + +dockerTest("Start-time sources: an override file and COMPOSE_FILE in .env are part of the checked model", async () => { + const over = project({ "docker-compose.yml": C(""), "docker-compose.override.yml": "services:\n app:\n privileged: true\n" }); + assert.equal((await gateDir(over, { textCheck: false })).valid, false); + const renamed = project({ "docker-compose.yml": C(""), ".env": "COMPOSE_PROJECT_NAME=crow-workspace\n" }); + assert.equal((await gateDir(renamed, { textCheck: false, manifest: { id: "fx-r" } })).valid, false, "COMPOSE_PROJECT_NAME can't take another bundle's project"); + const swapped = project({ "docker-compose.yml": C(""), "evil.yml": C(" network_mode: host\n"), ".env": "COMPOSE_FILE=evil.yml\n" }); + assert.equal((await gateDir(swapped, { textCheck: false })).valid, false); + const benign = project({ "docker-compose.yml": C(" volumes:\n - ./data:/d\n"), "docker-compose.override.yml": "services:\n app:\n volumes: !override\n - ./data:/var/lib/x\n" }); + assert.equal((await gateDir(benign, { textCheck: false })).valid, true, "an operator's benign override still starts"); +}); + +dockerTest("a required ${VAR:?} left blank (collection install) is filled with a harmless value so the rest is checked", async () => { + const dir = project({ "docker-compose.yml": C(" volumes:\n - ${MEDIA:?Set MEDIA}:/m\n") }); + assert.equal((await gateDir(dir)).valid, true); + const bad = project({ "docker-compose.yml": C(" volumes:\n - ${MEDIA:?Set MEDIA}:/m\n privileged: true\n") }); + assert.equal((await gateDir(bad, { textCheck: false })).valid, false); +}); + +dockerTest("a compose file Compose cannot read is refused (fail-closed)", async () => { + const r = await gateDir(project({ "docker-compose.yml": "services: [this, is, not, valid]\n" }), { textCheck: false }); + assert.equal(r.valid, false); + assert.match(r.reason, /Docker Compose could not read/); +}); + +dockerTest("every tracked first-party compose passes the model check (with its manifest + consent), laid out as on a real host", async () => { + // A real host: HOME/.crow is CROW_HOME and bundles run from CROW_HOME/bundles/. + const { cpSync } = await import("node:fs"); + const home = mkdtempSync(join(tmpdir(), "crow-cguard-realhome-")); + const ch = join(home, ".crow"); + const env = { PATH: process.env.PATH, HOME: home, CROW_HOME: ch }; + const run = (args, { cwd, env: e }) => new Promise((resolve, reject) => execFile("docker", ["compose", ...args], { cwd, env: e }, (err, stdout, stderr) => (err ? reject(Object.assign(err, { stderr })) : resolve({ stdout })))); + const bad = []; + try { + for (const id of [...trackedBundleSet()].sort()) { + const src = join(REPO, "bundles", id); + if (!existsSync(join(src, "docker-compose.yml"))) continue; + const manifest = JSON.parse(readFileSync(join(src, "manifest.json"), "utf8")); + if (manifest.inference === true) continue; // model bundles: Model Catalog + gpu-orchestrator, never this route + const dir = join(ch, "bundles", id); + cpSync(src, dir, { recursive: true, filter: (f) => !f.includes("node_modules") }); + const loaded = await G.loadComposeModel(dir, { env, runConfig: run }); + if (loaded.error) { bad.push(`${id}: ${loaded.error}`); continue; } + const { readdirSync } = await import("node:fs"); + const r = G.checkComposeModel(loaded.model, { bundleDir: dir, manifest, consentVerified: true, ctx: { crowHome: ch, dataDir: join(ch, "data"), appRoot: REPO, home, shipped: new Set(readdirSync(src)) } }); + if (!r.valid) bad.push(`${id}: ${r.reason}`); + } + } finally { rmSync(home, { recursive: true, force: true }); } + assert.deepEqual(bad, [], bad.join("\n")); +}); + +// ---- one check on every path that runs compose up ---------------------------------- + +function stage(id, compose, files = {}) { + const dir = join(FIXTURES, id); + mkdirSync(dir, { recursive: true }); + const manifest = { id, name: id, description: "d", type: "bundle", category: "infrastructure", version: "0.1.0", docker: { composefile: "docker-compose.yml" } }; + writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest)); + writeFileSync(join(dir, "docker-compose.yml"), compose); + for (const [n, t] of Object.entries(files)) writeFileSync(join(dir, n), t); + return manifest; +} +// Passes the text check, fails the model check: a YAML merge key that turns on privileged. +const MERGE_PRIV = `x-p: &p\n privileged: true\nservices:\n app:\n image: busybox\n <<: *p\n`; + +dockerTest("collection path: a member whose model fails is refused inside install-set, before any compose up", async () => { + stage("fx-col", MERGE_PRIV); + const colPath = join(FIXTURES, "collections.json"); + writeFileSync(colPath, JSON.stringify({ version: 1, collections: [{ id: "fxc", name: "Fx", description: "d", icon: "home", members: [{ id: "fx-col", kind: "deploys" }] }] })); + B._setCollectionsPathForTest(colPath); + upCalls.length = 0; + const app = express(); app.use(express.json()); app.use(B.default()); + const server = app.listen(0, "127.0.0.1"); await new Promise((r) => server.once("listening", r)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + const r = await fetch(`${base}/bundles/api/install-set`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ collection_id: "fxc" }) }); + const started = await r.json(); + const { job_id } = started; + assert.ok(job_id, `install-set started: ${JSON.stringify(started)}`); + let job; + for (let i = 0; i < 100; i++) { + job = await (await fetch(`${base}/bundles/api/jobs/${job_id}`)).json(); + if (job.status && job.status !== "running") break; + await new Promise((res) => setTimeout(res, 50)); + } + const log = (job.log || []).join("\n"); + assert.match(log, /Security check failed/); + assert.match(log, /SUMMARY member fx-col failed/); + assert.equal(upCalls.filter((a) => a[0] === "up" || a[0] === "pull").length, 0); + } finally { server.close(); B._setCollectionsPathForTest(null); } +}); + + +dockerTest("install path (single and collection both run runInstallJob): refused before any compose up", async () => { + const manifest = stage("fx-merge", MERGE_PRIV); + upCalls.length = 0; + const job = B._createJobForTest("fx-merge", "install"); + const out = await B.runInstallJob("fx-merge", {}, { job, installedSnapshot: [], consentVerified: false, manifest }); + assert.equal(out.ok, false); + assert.match(out.reason, /Security check failed/); + assert.equal(upCalls.length, 0, "no compose pull/up ran"); +}); + +dockerTest("Start path: an installed bundle whose model fails is not started", async () => { + const id = "fx-start"; + const manifest = stage(id, MERGE_PRIV); + const dir = join(CROW_HOME, "bundles", id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest)); + writeFileSync(join(dir, "docker-compose.yml"), MERGE_PRIV); + const installed = join(CROW_HOME, "installed.json"); + writeFileSync(installed, JSON.stringify([{ id, type: "bundle", version: "0.1.0" }])); + upCalls.length = 0; + const app = express(); app.use(express.json()); app.use(B.default()); + const server = app.listen(0, "127.0.0.1"); await new Promise((r) => server.once("listening", r)); + try { + const r = await fetch(`http://127.0.0.1:${server.address().port}/bundles/api/start`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ bundle_id: id }) }); + const body = await r.json(); + assert.equal(r.status, 403, JSON.stringify(body)); + assert.equal(body.code, "compose_security_check"); + assert.equal(upCalls.filter((a) => a[0] === "up").length, 0); + } finally { server.close(); } +}); + +test("model: a bundle may use its OWN subfolder of the data folder, never a sibling's or the folder itself", () => { + const m = { id: "rk" }; + assert.equal(check(svc({ volumes: [bind(join(DATA, "rk"))] }), { manifest: m }).valid, true); + assert.equal(check(svc({ volumes: [bind(join(DATA, "rk", "ws"))] }), { manifest: m }).valid, true); + assert.equal(check(svc({ volumes: [bind(join(DATA, "backups"))] }), { manifest: m }).valid, false); + assert.equal(check(svc({ volumes: [bind(DATA)] }), { manifest: m }).valid, false); + assert.equal(check(svc({ volumes: [bind(join(DATA, "rkx"))] }), { manifest: m }).valid, false, "a different id that only starts the same"); +}); + +test("model: more host areas — /root, /boot, Docker's data, /run, /dev are refused; /proc and /sys only read-only with consent", () => { + for (const src of ["/root", "/boot", "/var/lib/docker", "/run/systemd", "/dev/sda"]) { + assert.equal(check(svc({ volumes: [bind(src, true)] }), { manifest: PRIV, consent: true }).valid, false, src); + } + assert.equal(check(svc({ volumes: [bind("/proc", true)] })).valid, false); + assert.equal(check(svc({ volumes: [bind("/proc", true)] }), { manifest: { consent_required: true }, consent: true }).valid, true); + assert.equal(check(svc({ volumes: [bind("/sys")] }), { manifest: { consent_required: true }, consent: true }).valid, false, "read-write /sys"); +}); + +test("model: ipc host and host devices need privileged consent; GPU render nodes don't", () => { + for (const extra of [{ ipc: "host" }, { devices: [{ source: "/dev/sda", target: "/dev/sda", permissions: "rwm" }] }, { devices: ["/dev/net/tun:/dev/net/tun"] }]) { + assert.equal(check(svc(extra)).valid, false, JSON.stringify(extra)); + assert.equal(check(svc(extra), { manifest: PRIV, consent: true }).valid, true, JSON.stringify(extra)); + } + assert.equal(check(svc({ devices: [{ source: "/dev/dri", target: "/dev/dri" }, { source: "/dev/kfd", target: "/dev/kfd" }] })).valid, true); +}); + +test("model: a named volume binding a relative device is refused", () => { + assert.equal(check({ ...svc(), volumes: { v: { driver_opts: { type: "none", o: "bind", device: "../../data" } } } }).valid, false); + assert.equal(check({ ...svc(), volumes: { v: { driver_opts: { type: "nfs", o: "addr=10.0.0.1", device: ":/export" } } } }).valid, true, "a remote NFS volume reads nothing local"); +}); + +dockerTest("review fixtures: privileged via interpolation/yes/!!bool, long-syntax socket, // and ${R:-/} roots, COMPOSE_FILE colon form", async () => { + for (const files of [ + { "docker-compose.yml": C(" privileged: ${P:-true}\n") }, + { "docker-compose.yml": C(" privileged: !!bool true\n") }, + { "docker-compose.yml": C(" volumes:\n - type: bind\n source: /run/docker.sock\n target: /s\n") }, + { "docker-compose.yml": C(" volumes:\n - //:/host\n") }, + { "docker-compose.yml": C(" volumes:\n - ${R:-/}:/host\n") }, + { "docker-compose.yml": C(" volumes:\n - ${U:-../..}/data:/d\n") }, + { "docker-compose.yml": C(""), "evil.yml": C(" privileged: true\n"), ".env": "COMPOSE_FILE: evil.yml\n" }, + { "docker-compose.yml": `services:\n app:\n image: busybox\n volumes: [hv:/x]\nvolumes:\n hv:\n driver_opts: {type: none, o: bind, device: /}\n` }, + ]) { + const r = await gateDir(project(files), { textCheck: false }); + assert.equal(r.valid, false, JSON.stringify(files)); + } +}); + +// ---- allowlist: anything the check does not understand is refused -------------------- + +test("allowlist: unknown service or top-level keys are refused, even with privileged consent", () => { + assert.equal(check(svc({ some_future_key: true }), { manifest: PRIV, consent: true }).valid, false); + assert.equal(check({ ...svc(), models: { m: {} } }, { manifest: PRIV, consent: true }).valid, false); + assert.equal(check({ ...svc(), "x-anything": { privileged: true } }).valid, true, "x- extension fields are inert"); +}); + +test("allowlist: escape-capable service keys need privileged consent", () => { + for (const extra of [ + { cgroup_parent: "/" }, { device_cgroup_rules: ["a *:* rwm"] }, { external_links: ["other"] }, { use_api_socket: true }, + { post_start: [{ command: "id", privileged: true }] }, { pre_stop: [{ command: "id" }] }, { develop: { watch: [] } }, + { sysctls: { "kernel.shm_rmid_forced": "1" } }, { oom_score_adj: -1000 }, { runtime: "sysbox-runc" }, + { cap_add: ["SYS_TIME"] }, { cap_add: ["BPF"] }, { cap_add: ["LINUX_IMMUTABLE"] }, + { deploy: { resources: { reservations: { devices: [{ driver: "x", capabilities: ["tpu"] }] } } } }, + { volumes: [{ type: "bind", source: join(CROW_HOME, "bundles", "fx-z"), target: "/x", bind: { propagation: "rshared" } }] }, + { volumes: [{ type: "bind", source: join(CROW_HOME, "bundles", "fx-z"), target: "/x", bind: { selinux: "Z" } }] }, + ]) { + assert.equal(check(svc(extra)).valid, false, JSON.stringify(extra)); + } + assert.equal(check(svc({ cap_add: ["CHOWN", "NET_BIND_SERVICE"], oom_score_adj: -500, runtime: "nvidia", + deploy: { resources: { reservations: { devices: [{ driver: "nvidia", count: 1, capabilities: [["gpu"]] }] } } } })).valid, true); +}); + +test("allowlist: builds can't use host network, SSH agent, build secrets, privileged builds or remote contexts", () => { + const dir = bundleDir(); + for (const extra of [{ network: "host" }, { ssh: ["default"] }, { secrets: ["s"] }, { privileged: true }, { entitlements: ["network.host"] }]) { + assert.equal(check(svc({ build: { context: dir, ...extra } }), { dir }).valid, false, JSON.stringify(extra)); + } + assert.equal(check(svc({ build: { context: dir, dockerfile: "Dockerfile", args: { A: "1" } } }), { dir }).valid, true); +}); + +test("allowlist: networks and volumes — host network by name, non-bridge drivers, non-local volume drivers", () => { + assert.equal(check({ ...svc({ networks: { h: null } }), networks: { h: { name: "host", external: true } } }).valid, false); + assert.equal(check({ ...svc({ networks: { m: null } }), networks: { m: { driver: "macvlan" } } }).valid, false); + assert.equal(check({ ...svc({ networks: { f: null } }), networks: { f: { name: "crow-federation", external: true } } }).valid, true); + assert.equal(check(svc({ networks: { undeclared: null } })).valid, false); + assert.equal(check({ ...svc(), volumes: { v: { driver: "some-plugin" } } }).valid, false); + assert.equal(check(svc({ volumes: [{ type: "volume", source: "undeclared", target: "/x" }] })).valid, false); +}); + +test("allowlist: secrets/configs can't copy a variable from the gateway's environment", () => { + assert.equal(check({ ...svc(), secrets: { s: { environment: "ANY_GATEWAY_SECRET" } } }, { manifest: PRIV, consent: true }).valid, false); + assert.equal(check({ ...svc(), configs: { c: { content: "inline" } } }).valid, true); +}); + +dockerTest("allowlist on real Compose output: use_api_socket, a host network by name, a secret from the environment", async () => { + for (const files of [ + { "docker-compose.yml": C(" use_api_socket: true\n") }, + { "docker-compose.yml": C(" networks: [h]\n") + "networks:\n h:\n name: host\n external: true\n" }, + { "docker-compose.yml": C(" secrets: [s]\n") + "secrets:\n s:\n environment: PATH\n" }, + { "docker-compose.yml": C(" build:\n context: .\n network: host\n") }, + ]) { + const r = await gateDir(project(files), { textCheck: false }); + assert.equal(r.valid, false, JSON.stringify(files)); + } +}); + +// ---- authorization: whose consent a later Start runs with ---------------------------- + +function seedInstalled(id, manifest, record, { repo = manifest } = {}) { + const dir = join(CROW_HOME, "bundles", id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "manifest.json"), JSON.stringify({ id, name: id, version: "0.1.0", type: "bundle", ...manifest })); + if (repo) { + mkdirSync(join(FIXTURES, id), { recursive: true }); + writeFileSync(join(FIXTURES, id, "manifest.json"), JSON.stringify({ id, name: id, version: "0.1.0", type: "bundle", ...repo })); + } + const path = join(CROW_HOME, "installed.json"); + const list = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : []; + writeFileSync(path, JSON.stringify([...list.filter((x) => x.id !== id), { id, type: "bundle", version: "0.1.0", ...record }])); +} + +test("install records the consent the owner gave, also for a bundle that asked for none", () => { + assert.deepEqual(B._consentRecordForTest({ privileged: true }, true), { consent: { verified: true, privileged: true, consent_required: false } }); + assert.deepEqual(B._consentRecordForTest({}, true), { consent: { verified: false, privileged: false, consent_required: false } }); +}); + +test("Start runs with the install-time consent: never a peer's, never for privileges added later", () => { + seedInstalled("fx-c1", { privileged: true }, B._consentRecordForTest({ privileged: true }, true)); + assert.equal(B._startConsentForTest("fx-c1").consentVerified, true); + assert.equal(B._startConsentForTest("fx-c1", { crossHost: true }).consentVerified, false, "a peer instance can't use the owner's consent"); + seedInstalled("fx-c2", { privileged: true, consent_required: true }, B._consentRecordForTest({ consent_required: true }, true)); + const sc = B._startConsentForTest("fx-c2"); + assert.equal(sc.manifest.privileged, false, "privileged was never consented to"); + assert.equal(sc.manifest.consent_required, true); + seedInstalled("fx-c3", { privileged: true }, { consent: { verified: false, privileged: true } }); + assert.equal(B._startConsentForTest("fx-c3").consentVerified, false); + seedInstalled("fx-c4", { privileged: true }, {}); + assert.equal(B._startConsentForTest("fx-c4").consentVerified, true, "a record from before consent was kept: the repo flags, as of then"); + assert.equal(B._startConsentForTest("fx-not-installed").consentVerified, false); +}); + +test("Start takes trust flags from the repo manifest, never the installed copy (a container can rewrite that)", () => { + // Installed copy rewritten to claim privileged/inference/first-party; the repo says none of it. + seedInstalled("fx-tamper", { privileged: true, inference: true, origin: "official" }, B._consentRecordForTest({}, true), { repo: { origin: "community" } }); + const sc = B._startConsentForTest("fx-tamper"); + assert.equal(sc.manifest.privileged, false); + assert.equal(sc.manifest.inference, false); + assert.equal(sc.manifest.origin, "community"); + assert.equal(sc.consentVerified, false); + // A legacy record (no consent field) for a bundle the repo never made privileged. + seedInstalled("fx-tamper2", { privileged: true }, {}, { repo: {} }); + assert.equal(B._startConsentForTest("fx-tamper2").manifest.privileged, false); + // A bundle gone from the repo: treated as community, nothing privileged. + seedInstalled("fx-gone", { privileged: true }, {}, { repo: null }); + const g = B._startConsentForTest("fx-gone"); + assert.equal(g.manifest.privileged, false); + assert.equal(g.manifest.origin, "community"); +}); + +test("a bundle can't mount its own control files read-write, nor claim a Crow name or file in CROW_HOME by its id", () => { + const dir = join(CROW_HOME, "bundles", "fx-ctl"); + mkdirSync(join(dir, "data"), { recursive: true }); + const m = { manifest: { id: "fx-ctl" }, dir }; + for (const rel of ["", "manifest.json", "docker-compose.yml", ".env", "server", "panel"]) { + assert.equal(check(svc({ volumes: [bind(join(dir, rel))] }), m).valid, false, `rw ${rel || "(the folder)"}`); + } + assert.equal(check(svc({ volumes: [bind(dir, true)] }), m).valid, true, "read-only is fine"); + assert.equal(check(svc({ volumes: [bind(join(dir, "data"))] }), m).valid, true, "a data subfolder is fine"); + for (const id of ["mcp", "peer", "secrets", "backups", "panels", "skills", "data", "bundles"]) { + for (const f of [id, `${id}-addons.json`, `${id}-tokens.json`]) { + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, f))] }), { manifest: { id } }).valid, false, `${id} → ${f}`); + } + } + writeFileSync(join(CROW_HOME, "board-token"), "x"); + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, "board-token"), true)] }), { manifest: { id: "board-token" } }).valid, false, "an existing file named like the id"); + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, "fx-ctl-tokens"))] }), m).valid, false, "only and -downloads"); + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, "fx-ctl-downloads"))] }), m).valid, true); +}); + +test("no tracked compose bundle's id is a name Crow reserves in CROW_HOME", async () => { + const { RESERVED_HOME_NAMES } = G; + const clash = [...trackedBundleSet()].filter((id) => existsSync(join(REPO, "bundles", id, "docker-compose.yml")) && RESERVED_HOME_NAMES.has(id)); + assert.deepEqual(clash, []); +}); + +dockerTest("Compose never sees the gateway's secrets: ${ANY_VAR} interpolates to empty", async () => { + process.env.CROW_TEST_GATEWAY_SECRET = "gw-secret-value-xyz"; + try { + const dir = project({ "docker-compose.yml": C(" environment:\n - LEAK=${CROW_TEST_GATEWAY_SECRET:-}\n") }); + const run = (args, { cwd, env }) => new Promise((resolve, reject) => execFile("docker", ["compose", ...args], { cwd, env }, (e, stdout, stderr) => (e ? reject(Object.assign(e, { stderr })) : resolve({ stdout })))); + const { model } = await G.loadComposeModel(dir, { env: B.composeEnv(), runConfig: run }); + assert.ok(model, "model loaded"); + assert.ok(!JSON.stringify(model).includes("gw-secret-value-xyz")); + } finally { delete process.env.CROW_TEST_GATEWAY_SECRET; } +}); + +dockerTest("services behind an inactive profile are checked too (a hook or an operator can start them)", async () => { + const dir = project({ "docker-compose.yml": C("") + " extra:\n image: busybox\n profiles: [bridge]\n privileged: true\n" }); + assert.equal((await gateDir(dir, { textCheck: false })).valid, false); +}); + +dockerTest("managed hosting: host networking is refused from the resolved model too (${N:-host}, LAN-level drivers)", async () => { + const { validateInstall } = B; + const prev = process.env.CROW_HOSTED; + process.env.CROW_HOSTED = "1"; + try { + for (const [id, compose] of [ + ["fx-hosted-a", C(" network_mode: ${N:-host}\n")], + ["fx-hosted-b", C(" networks: [lan]\n") + "networks:\n lan:\n driver: macvlan\n"], + ]) { + stage(id, compose); + const r = await validateInstall(id, { forceInstall: true }); + assert.equal(r.code, "hosted_forbidden", `${id}: ${JSON.stringify(r)}`); + } + } finally { if (prev === undefined) delete process.env.CROW_HOSTED; else process.env.CROW_HOSTED = prev; } +}); + +// ---- review round A ------------------------------------------------------------------ + +test("devices: a GPU-looking path that climbs out (/dev/dri/../nvme0n1) is a raw disk, not a GPU", () => { + for (const src of ["/dev/dri/../nvme0n1", "/dev/dri/../../etc", "/dev/kfd/.."]) { + assert.equal(check(svc({ devices: [{ source: src, target: "/dev/dri", permissions: "rwm" }] })).valid, false, src); + } + assert.equal(check(svc({ devices: [{ source: "/dev/dri/renderD128", target: "/dev/dri/renderD128" }] })).valid, true); +}); + +test("inside Crow's home only the bundle's own places are mountable (peer tokens, identity, keystores are not)", () => { + const m = { manifest: { id: "fx" } }; + for (const f of ["peer-tokens.json", "identity.json", "board-token", ".keystore-env", "android-keystore", "installed.json", "panels", "skills", "other-bundle"]) { + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, f), true)] }), m).valid, false, f); + } + for (const f of ["fx", "fx-downloads", join("media", "fx")]) { + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, f))] }), m).valid, true, f); + } + assert.equal(check(svc({ volumes: [bind(join(CROW_HOME, "media"))] }), m).valid, false, "the whole media folder"); +}); + +test("host code and config locations are refused: system dirs, the gateway checkout, home dot-folders, ~/bin", () => { + const home = homedir(); + for (const src of ["/usr/local/bin", "/opt/x", "/var/spool/cron", "/var/lib/x", "/lib/systemd", join(home, ".config", "systemd", "user"), + join(home, ".bashrc"), join(home, ".local", "bin"), join(home, "bin"), join(REPO, "servers"), join(REPO, ".env")]) { + assert.equal(check(svc({ volumes: [bind(src)] }), { manifest: PRIV, consent: true }).valid, false, src); + } + for (const src of [join(home, "Music"), "/srv/media", "/mnt/external/films", "/media/usb"]) { + assert.equal(check(svc({ volumes: [bind(src, true)] })).valid, true, src); + } +}); + +test("external volumes (another project's data) and external networks other than crow-federation need privileged consent", () => { + assert.equal(check({ ...svc({ volumes: [{ type: "volume", source: "db", target: "/d" }] }), volumes: { db: { external: true, name: "crow-workspace_db" } } }).valid, false); + assert.equal(check({ ...svc({ networks: { n: null } }), networks: { n: { external: true, name: "crow-workspace_internal" } } }).valid, false); + assert.equal(check({ ...svc({ networks: { f: null } }), networks: { f: { external: true, name: "crow-federation" } } }).valid, true); +}); + +test("a signed peer instance can't install, mint consent, uninstall or re-apply storage here", async () => { + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.crossHostAuth = { sourceInstanceId: "peer-x" }; next(); }); + app.use(B.default()); + const server = app.listen(0, "127.0.0.1"); await new Promise((r) => server.once("listening", r)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + for (const [method, path] of [["POST", "/bundles/api/install"], ["POST", "/bundles/api/install-set"], ["GET", "/bundles/api/consent-challenge/browser"], + ["POST", "/bundles/api/uninstall"], ["POST", "/bundles/api/shared-storage/apply/funkwhale"], ["POST", "/bundles/api/repin"], + ["POST", "/bundles/api/INSTALL"], ["POST", "/bundles/api/install/"], ["GET", "/bundles/api/Consent-Challenge/browser"], + ["GET", "/BUNDLES/api/consent-challenge/browser"], ["POST", "/bundles/api/Install-Set/"], ["POST", "/bundles/api//install"]]) { + const r = await fetch(base + path, { method, headers: { "content-type": "application/json" }, ...(method === "POST" ? { body: JSON.stringify({ bundle_id: "browser" }) } : {}) }); + assert.equal(r.status, 403, `${method} ${path}`); + } + } finally { server.close(); } +}); + +test("code-execution paths are refused: folders on PATH, any git checkout, systemd/cron/docker dirs, shell rc and autostart", () => { + const home = homedir(); + const repoLike = mkdtempSync(join(tmpdir(), "crow-cguard-git-")); + mkdirSync(join(repoLike, ".git"), { recursive: true }); + mkdirSync(join(repoLike, "scripts"), { recursive: true }); + for (const src of [repoLike, join(repoLike, "scripts"), "/etc/systemd/system", "/var/spool/cron/crontabs", "/var/lib/docker", "/var/lib/containerd", + "/usr/local/bin", join(home, ".config", "autostart"), join(home, ".profile"), join(home, ".bashrc"), join(home, ".ssh")]) { + assert.equal(check(svc({ volumes: [bind(src)] }), { manifest: PRIV, consent: true }).valid, false, src); + } + const onPath = mkdtempSync(join(tmpdir(), "crow-cguard-path-")); + assert.equal(G.checkComposeModel(svc({ volumes: [bind(onPath)] }), { bundleDir: bundleDir(), manifest: {}, consentVerified: false, ctx: { ...CTX, path: `/usr/bin:${onPath}` } }).valid, false, "a folder on PATH"); + rmSync(repoLike, { recursive: true, force: true }); + rmSync(onPath, { recursive: true, force: true }); +}); + +test("data folder: or -* only, never a name Crow's data uses and never an existing file", () => { + for (const [id, rel] of [["backups", "backups"], ["peers", "peers"], ["instance", "instance-id"], ["identity", "identity.json"], ["tasks", "tasks.db"]]) { + if (rel === "instance-id") writeFileSync(join(DATA, "instance-id"), "x"); + assert.equal(check(svc({ volumes: [bind(join(DATA, rel))] }), { manifest: { id } }).valid, false, `${id} → ${rel}`); + } + assert.equal(check(svc({ volumes: [bind(join(DATA, "rk-workspaces"))] }), { manifest: { id: "rk" } }).valid, true); +}); + +test("a bind mount inside another read-write bind mount is refused (the container could swap it for a symlink)", () => { + const dir = bundleDir(); + const outer = join(DATA, "nest"); + assert.equal(check(svc({ volumes: [bind(outer), bind(join(outer, "inner"))] }), { manifest: { id: "nest" }, dir }).valid, false); + assert.equal(check(svc({ volumes: [bind(outer, true), bind(join(outer, "inner"), true)] }), { manifest: { id: "nest" }, dir }).valid, true, "read-only outer is fine"); + assert.equal(check({ services: { a: { image: "x", volumes: [bind(outer)] }, b: { image: "x", volumes: [bind(join(outer, "x"), true)] } } }, { manifest: { id: "nest" }, dir }).valid, false, "across services too"); +}); + +test("no tracked compose bundle's id is a name Crow's data folder reserves", () => { + const clash = [...trackedBundleSet()].filter((id) => existsSync(join(REPO, "bundles", id, "docker-compose.yml")) && G.RESERVED_DATA_NAMES.has(id)); + assert.deepEqual(clash, []); +}); + +test("anything the bundle ships (settings-section.js, run.sh, src/…) is never mountable read-write", () => { + const dir = join(CROW_HOME, "bundles", "fx-ship"); + mkdirSync(dir, { recursive: true }); + const ctx = { ...CTX, shipped: new Set(["settings-section.js", "run.sh", "src", ".venv"]) }; + for (const rel of ["settings-section.js", "run.sh", "src", ".venv"]) { + assert.equal(G.checkComposeModel(svc({ volumes: [bind(join(dir, rel))] }), { bundleDir: dir, manifest: { id: "fx-ship" }, ctx }).valid, false, rel); + assert.equal(G.checkComposeModel(svc({ volumes: [bind(join(dir, rel), true)] }), { bundleDir: dir, manifest: { id: "fx-ship" }, ctx }).valid, true, `${rel} ro`); + } + assert.equal(G.checkComposeModel(svc({ volumes: [bind(join(dir, "data"))] }), { bundleDir: dir, manifest: { id: "fx-ship" }, ctx }).valid, true, "a runtime data folder"); +}); + +test("cross-bundle: no other bundle's project, volume or network names, and no prefix-sibling folders", () => { + const m = { manifest: { id: "fx-x" } }; + assert.equal(check({ name: "crow-workspace", ...svc() }, m).valid, false, "another bundle's project"); + assert.equal(check({ name: "fx-x", ...svc() }, m).valid, true); + assert.equal(check({ name: "crow-fx-x-r4", ...svc() }, m).valid, true, "a second instance's project"); + assert.equal(check({ name: "fx-x", ...svc({ volumes: [{ type: "volume", source: "v", target: "/v" }] }), volumes: { v: { name: "crow-workspace_db" } } }, m).valid, false); + assert.equal(check({ name: "fx-x", ...svc({ volumes: [{ type: "volume", source: "v", target: "/v" }] }), volumes: { v: { name: "fx-x_v" } } }, m).valid, true); + assert.equal(check({ name: "fx-x", ...svc({ networks: { n: null } }), networks: { n: { name: "crow-workspace_default" } } }, m).valid, false); + assert.equal(check(svc({ volumes: [bind(join(DATA, "rook-x"))] }), { manifest: { id: "rook" } }).valid, false, "id rook can't reach bundle rook-x's data"); + assert.equal(check(svc({ volumes: [bind(join(DATA, "rook-workspaces"))] }), { manifest: { id: "rook" } }).valid, true); +}); + +test("no tracked bundle id ends in an owned-place suffix (so the suffix rule can't reach another bundle)", () => { + const bad = [...trackedBundleSet()].filter((id) => G.OWN_SUFFIXES.some((x) => id.endsWith(x))); + assert.deepEqual(bad, []); +}); diff --git a/tests/phone-installed-layout.test.js b/tests/phone-installed-layout.test.js index 88afd2b49..604387403 100644 --- a/tests/phone-installed-layout.test.js +++ b/tests/phone-installed-layout.test.js @@ -50,10 +50,10 @@ test("installed mcp.js builds a server with the injected McpServer and z", async assert.ok(server instanceof McpServer); }); -test("bundle version 0.2.4 (generated runner secret) — the manifest and the MCP server agree", async () => { +test("bundle version 0.2.5 (generated runner secret; consent text in en + es) — the manifest and the MCP server agree", async () => { const { readFileSync } = await import("node:fs"); const manifest = JSON.parse(readFileSync(join(ROOT, "bundles", "phone", "manifest.json"), "utf8")); - assert.equal(manifest.version, "0.2.4"); + assert.equal(manifest.version, "0.2.5"); const mcpSrc = readFileSync(join(SRC, "mcp.js"), "utf8"); - assert.match(mcpSrc, /new McpServer\(\{ name: "crow-phone", version: "0\.2\.4" \}\)/); + assert.match(mcpSrc, /new McpServer\(\{ name: "crow-phone", version: "0\.2\.5" \}\)/); });