Repository navigation
fix(bundles): the install security check reads what Compose applies, on every compose-up path; our own bundles pass it - #508
Merged
Conversation
…test keeps it that way Eight first-party bundles were refused by validateComposeFile even with consent: gitea, forgejo and frigate (read-only /etc timezone mounts), companion, coturn, plex and tailscale (host networking without the privileged marker) and netdata (a comment named a blocked capability). - The check allows exactly one /etc mount: /etc/localtime, read-only, same path, short syntax, not continued on the next line. gitea and forgejo drop their /etc/timezone mount. - companion, coturn, plex and tailscale are privileged, with English and Spanish consent text that names what each one gets. browser and phone gain the missing consent text. - netdata's comment no longer names the capability. The check still reads the file as text, comments included: stripping comments let crafted files through. - Hardening on the same rules: privileged True or a quoted key, cap_add ALL, quoted network_mode, long-syntax source: mounts, a second compose file next to docker-compose.yml, and community-origin bundles never get privileged powers. The managed-hosting host-network refusal runs before the consent check. - nominatim, sdxl and frigate 8555 publish on 127.0.0.1 only. - tests/bundle-compose-gate.test.js runs the real check over every tracked bundle, requires consent text where a compose needs consent, and fails on a first-party port published on every interface. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
… never read - A COMPOSE_* line in the bundle's .env or .env.example (COMPOSE_FILE swaps the checked file) refuses the install, and install/Configure refuse COMPOSE_* and DOCKER_* keys, so a later Start can't be steered either. - include: and extends: file: are refused. - env_file, secrets/configs file: and the build context must be relative paths inside the bundle (model bundles keep their shared GPU env file; the Model Catalog installs them, never this route). - A mount source with a .. step is refused (../../data is the Crow data dir). - network_mode container:, pid host/container: and userns_mode host need privileged + consent, like host networking. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…y path that runs compose up
The install check read docker-compose.yml as text, so anything Compose
resolves differently got through: YAML merge keys and anchors, flow/JSON
style, ${VAR} paths and defaults, include/extends, override files,
COMPOSE_FILE in .env, symlinks inside the bundle, bind-device volumes.
- servers/gateway/compose-guard.js asks Compose for the model it will apply
(docker compose config --format json, same cwd and env as up) and checks
that, resolving host paths through symlinks (dangling ones too) and
checking both the written and the real path. Compose failing to read the
project refuses the install (fail-closed); a ${VAR:?} a collection install
left blank gets a harmless in-bundle value so the rest is still checked.
- Refused: /, home, /etc (except /etc/localtime read-only), /root, /boot,
/run, /dev, ~/.ssh, Docker's data, Crow's folder, data, secrets, .env,
mcp-addons.json, other extensions' folders, and any parent of these.
/proc and /sys read-only need consent. A bundle may use its own
<data>/<id> subfolder; with privileged consent, crow.db and the
extensions folder read-only (companion; its consent text says so).
- env_file, secrets/configs files and build contexts stay inside the bundle.
- privileged, dangerous caps, host/container network, pid/ipc/userns/uts/
cgroup host, unconfined security_opt, volumes_from and non-GPU devices
need privileged + consent; never for community bundles.
- checkBundleCompose is the one check: install (single and collection run
runInstallJob), Start, and the shared-storage re-apply (after the env
injection). Tests cover each path.
- vllm uses shm_size instead of ipc: host; companion's optional model folder
defaults to a folder inside the bundle (an empty path broke compose up).
Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ll-time consent; Compose gets no gateway secrets - compose-guard checks every key Compose emits against an allowlist: a service, build, deploy, volume, network, secret or config key it does not understand refuses the bundle. Escape-capable keys (cgroup_parent, device_cgroup_rules, external_links, use_api_socket, lifecycle hooks, develop, sysctls, oom_score_adj below -500, non-default runtimes, caps outside a safe set, bind propagation, SELinux relabels, non-GPU device reservations, host networks by name, macvlan-style drivers, non-local volume drivers, build network/ssh/secrets/privileged, remote build contexts) need privileged + consent or are refused. secrets/configs 'environment:' is refused: it copies a gateway variable into the container. - installed.json records what the owner consented to at install. Start and the shared-storage re-apply run with that consent, never a peer instance's, and not for privileges a later manifest refresh added. - composeEnv passes Compose only an allowlist of the gateway's variables, so a compose file can no longer interpolate a gateway secret into its container. A bundle's own values still come from its .env. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…fuses host networking from the resolved model
- loadComposeModel asks Compose for every profile, so a service behind an
inactive profile (which a hook or an operator can start) is checked too.
- The managed-hosting host-network refusal also reads the resolved model:
${N:-host} and macvlan/ipvlan networks are refused; a model that can't be
read is refused there.
Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ice paths are normalized; peers can't install - Inside CROW_HOME and the data folder only the bundle's own places mount: its folder, CROW_HOME/<id> or <id>-*, <data>/<id>, CROW_HOME/media/*, and with privileged consent crow.db and the extensions folder read-only. Peer tokens, identity, keystores and other bundles' folders are refused. - Also refused: system folders (/usr, /opt, /var, /lib…), the gateway checkout, home dot-folders and ~/bin, and any folder containing these. - A device counts as a GPU render node only after normalizing and resolving it (/dev/dri/../nvme0n1 is a raw disk). - External volumes, and external networks other than crow-federation, need privileged consent. - A signed peer instance can't install, mint a consent token, uninstall, re-pin or re-apply shared storage on this instance. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…Express routes on Express matches routes case-insensitively and ignores a trailing slash, so /INSTALL or /install/ reached the install handler past the exact-path check. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…im Crow's own names or rewrite its control files - Start takes privileged / consent_required / origin / inference from the reviewed repo manifest, capped by the consent recorded at install, never from the installed copy (the bundle's own container can mount that folder). A bundle gone from the repo counts as community. Install now always records consent, so a missing record only means 'installed before this was kept'. - A bundle can't mount its own folder or its control files (manifest, compose files, .env, server/, panel/, scripts/ ...) read-write. - In CROW_HOME only <id> (a folder, or not created yet) and <id>-downloads count as the bundle's own; never a name Crow itself uses there (data, secrets, panels, skills, backups, mcp, peer ...) and never an existing file. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ders and git checkouts Added to the host-path denylist (the allowlist redesign stays a follow-up): every folder on the gateway's PATH, and any git working tree (another Crow checkout, lab scripts that timers run). Already refused and now tested: systemd, cron, docker and containerd dirs, /usr/local/bin, shell rc files, ~/.config/autostart, ~/.ssh. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ad-write bind; shipped files never read-write - In the data folder a bundle's own places are <data>/<id> and <data>/<id>-* (a folder, or not created yet), never a name Crow's data uses (backups, peers, identity, instance-id...) and never an existing file. - A bind source inside another read-write bind of the project is refused: the container could swap it for a symlink that Docker follows at the next restart, which nothing re-checks. rookery's workspaces default moves beside its data folder (rookery 0.1.2). - Every top-level entry a bundle ships (settings-section.js, run.sh, src/, .venv ...) is refused as a read-write mount. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
… network names, or its prefix-sibling folders - The compose project name must be <id>, crow-<id>, or either plus -<suffix> (a second instance): name: crow-workspace would reuse Workspace's volumes. - A non-external volume or network may not be named outside the project. - Beside <id>, only fixed suffixes count as the bundle's own folders (-downloads, -workspaces, -data, -cache), so id rook never reaches bundle rook-x's data; a test keeps bundle ids off those suffixes. - A Compose model without a project name is refused. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
… use it), held to the bundle's own project A second instance on the same host names its project with COMPOSE_PROJECT_NAME in the bundle .env; refusing it would break reinstalls there. The model check holds the resulting project name to <id> / crow-<id> (+ -suffix). The ownership test's fixture uses such a name, so the ownership guard is still what it exercises. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Known gaps (read first)
~/.crow/data/<id>mount that the model check would allow; that is stricter, not a hole.summarizePrivileges) is still built from text patterns. It does not list every grant the model check gates (devices, ipc/pid/userns, security_opt, volumes_from)./srvfolder). That is the denylist-vs-allowlist follow-up below.uprace. Nothing locks the Start check tocompose up, so a peer Configure write could land between them.COMPOSE_PROJECT_NAME) came after the final review round (D). They are covered by tests but have no fresh review.Why
Queue item 30, fix A (one-click audit). The install security check (
validateComposeFile) refused 8 of our own bundles even with consent: gitea, forgejo, frigate, companion, coturn, plex, tailscale and netdata. No test ran it over our own compose files.Reviewing the fix showed the check only read
docker-compose.ymlas text, ran only at install, and could be bypassed in many ways. Every one of these exists on main today:${VAR}paths;COMPOSE_FILEin.env;What changes
/etc/localtimeis allowed read-only./etc/timezonemount.shm_sizeinstead ofipc: host.servers/gateway/compose-guard.jschecks what Compose will actually run. It runsdocker compose config --format jsonwith all profiles, the same working directory, and an allowlisted env, then checks the result against an allowlist: an unknown key or value is refused, and so is a project Compose can't read.~/bin, and anything that contains one of these.checkBundleCompose, runs on install (single and collection), on Start and on the shared-storage re-apply.composeEnvpasses Compose an allowlist of gateway variables;COMPOSE_*/DOCKER_*keys are refused, exceptCOMPOSE_PROJECT_NAME.Accepted behaviour
Follow-ups (accepted, queue item 30)
servers/gateway/gpu-orchestrator.jsrunscompose upfor model bundles outside this check and with the full gateway env.Tests
tests/bundle-compose-gate.test.jsruns the real check over every tracked bundle. It covers consent text, all-interface binds and the text-check rules.tests/compose-guard.test.jscovers the allowlist, path policy, differential cases checked with realdocker compose config, every compose-up path, consent, peers, env and names. All bundles are also checked laid out as on a real host.Reviews
Version bumps
browser 1.3.6, companion 1.0.3, coturn 1.0.2, plex 1.0.2, tailscale 1.0.1, frigate 1.0.1, nominatim 1.0.3, sdxl 1.0.1, netdata 1.0.1, gitea 1.0.1, forgejo 1.0.1, phone 0.2.5, vllm 1.0.1, rookery 0.1.2.
Deploy
Gateway code; restart
crow-gatewayand r4's gateway on the same tree. Merge order: this PR, then #504, then #505, with one restart.https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp