Skip to content

fix(bundles): the install security check reads what Compose applies, on every compose-up path; our own bundles pass it - #508

Merged
kh0pper merged 13 commits into
mainfrom
fix/bundle-install-guard
Oct 16, 2026
Merged

kh0pper merged 13 commits into
mainfrom
fix/bundle-install-guard

Conversation

@kh0pper

@kh0pper kh0pper commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Known gaps (read first)

  • Denylist and list mismatches.
    • The install-time text check and the model check don't agree everywhere. The text check refuses a literal ~/.crow/data/<id> mount that the model check would allow; that is stricter, not a hole.
    • The consent dialog's capability summary (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).
  • Cross-bundle access. This PR closes another bundle's project, volume and network names, its folders, and data-folder or CROW_HOME names. A bundle can still reach another bundle's data through a host path outside Crow's home that both point at (e.g. a shared /srv folder). That is the denylist-vs-allowlist follow-up below.
  • Check vs up race. Nothing locks the Start check to compose up, so a peer Configure write could land between them.
  • Unreviewed commits. The last commits (data-folder and project-name rules, PATH and git-checkout paths, 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.yml as text, ran only at install, and could be bypassed in many ways. Every one of these exists on main today:

  • what the text check misses:
    • YAML anchors and merge keys;
    • JSON-style compose files;
    • interpolated ${VAR} paths;
    • long-syntax mounts;
    • a read-write Docker socket;
    • override files;
    • COMPOSE_FILE in .env;
  • no check at all at Start or at the shared-storage re-apply;
  • a signed peer could mint a consent token and install;
  • the whole gateway environment was passed to Compose, so a compose file could interpolate any gateway secret into a container;
  • secret and code paths under the Crow home were mountable.

What changes

  • Our bundles pass.
    • /etc/localtime is allowed read-only.
    • companion, coturn, plex and tailscale are marked privileged, with honest en/es consent text; browser and phone get the consent text they lacked.
    • netdata's comment is reworded; gitea and forgejo drop the /etc/timezone mount.
    • nominatim, sdxl and frigate's 8555 bind to 127.0.0.1.
    • vllm uses shm_size instead of ipc: host.
    • companion's optional model folder defaults to a folder inside the bundle.
    • rookery's workspaces folder moves beside its data folder.
  • servers/gateway/compose-guard.js checks what Compose will actually run. It runs docker compose config --format json with 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.
  • Host-path policy.
    • Inside Crow's home and the data folder, an allowlist of the bundle's own places, and never a name Crow uses there.
    • Never the bundle's own control files or the files it ships, read-write.
    • Never a bind mount inside another read-write bind mount.
    • Elsewhere refused: system dirs, the gateway checkout, every folder on PATH, any git checkout, home dot-folders and ~/bin, and anything that contains one of these.
    • Symlinks are resolved, dangling ones included.
  • Project, volume and network names stay the bundle's own.
  • One shared check, checkBundleCompose, runs on install (single and collection), on Start and on the shared-storage re-apply.
  • Consent. It is recorded at install. Start takes the trust flags from the reviewed repo manifest, capped by that consent, never from the installed copy. A signed peer can't install, mint consent, uninstall, re-pin or re-apply storage; the path check normalizes case, trailing slashes and doubled slashes.
  • composeEnv passes Compose an allowlist of gateway variables; COMPOSE_*/DOCKER_* keys are refused, except COMPOSE_PROJECT_NAME.
  • Managed hosting refuses host networking judged from the resolved model.

Accepted behaviour

  • Privileged bundles can't be started from a paired instance.

Follow-ups (accepted, queue item 30)

  • servers/gateway/gpu-orchestrator.js runs compose up for model bundles outside this check and with the full gateway env.
  • The host-path policy outside Crow's home is a denylist. Replacing it with an allowlist needs to tell user-typed values from bundle-shipped ones.
  • frigate's live install keeps its old compose file, because a refresh never copies compose files.

Tests

  • tests/bundle-compose-gate.test.js runs the real check over every tracked bundle. It covers consent text, all-interface binds and the text-check rules.
  • tests/compose-guard.test.js covers the allowlist, path policy, differential cases checked with real docker compose config, every compose-up path, consent, peers, env and names. All bundles are also checked laid out as on a real host.
  • Full suite: 9632 tests, 0 failures.

Reviews

  • Plan review, code reviews 1 and 2, and security reviews A, B, C and D, all in the private engineering notes.
  • D's Important items are fixed with tests, in the post-review commits listed under Known gaps.

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-gateway and r4's gateway on the same tree. Merge order: this PR, then #504, then #505, with one restart.

https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant