Skip to content

fix(bundles): outside Crow's home, extensions mount only folders the owner allowed - #524

Merged
kh0pper merged 5 commits into
mainfrom
fix/item30-b
Oct 19, 2026
Merged

kh0pper merged 5 commits into
mainfrom
fix/item30-b

Conversation

@kh0pper

@kh0pper kh0pper commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Why

Inside Crow's home and data folder, the install security check is an allowlist. Outside them it was a denylist: system folders, PATH folders, the checkout, home dot-folders, ~/bin and git checkouts were refused, and everything else could be mounted read-write, whoever chose it. That meant a bundle default, a .env.example value, or a paired instance setting a path through Configure and then pressing Start (security review D).

What changes

  • Outside Crow's home and data folder, only these pass:
    • fixed entries: /etc/localtime read-only; /proc, /sys and the Docker socket read-only with consent (these rules are unchanged);
    • folders the owner allowed for this extension (folder grants).
  • Folder grants (servers/gateway/folder-grants.js, store CROW_HOME/folder-grants.json, written only by the gateway):
    • Who can grant: only the owner, at this machine's own dashboard. That means a local password session (oauth_tokens.scopes === "dashboard", servers/gateway/dashboard/local-session.js): never a signed peer request, and never a session minted by a peer sign-in.
    • What gets granted: only a value the owner typed, at install or in Configure, for a setting the compose file uses as a mount source. A value never counts if it is the manifest's default/example, the .env.example value, a retained value, or a secret key's value. The modals prefill defaults, so a default is never taken as a choice.
    • Only changed values. Configure grants, and drops, only keys whose value this save changes, compared with the .env before the save. Install skips a value the earlier install's .env already held. So a value something else wrote to the .env (a hand edit, a CLI install, a container that can write it) is never granted by an unrelated Save.
    • Grants are recorded only after every pre-up check of the install passed.
    • What a grant is: key + folder. It counts only while that setting still names the folder. A hand edit, a CLI value or a peer's value is therefore not granted. Uninstall keeps grants, so a reinstall that restores a kept library path still starts.
    • Limits on an allowed folder: it must itself pass the floor (system folders, PATH folders, the gateway checkout, home dot-folders and ~/bin, a whole home folder or the homes root, a git checkout, anything containing or inside Crow's home or data). It must be at least two levels deep. It may not sit inside, or contain, another extension's allowed folder. The same folder is fine, but not while the other extension mounts a folder inside it: its compose model is read, and if it can't be read the grant is refused.
  • A non-owner (a signed peer request, or a peer sign-in session) may not point a setting the compose file uses at a host path: anything Compose could resolve outside the bundle (absolute or ~/, a ../ segment, an interpolation; $$ is a literal $). Configure and install both answer 403 folder_needs_owner. URLs, time zones, tokens and ./sub values still work.
  • A folder two extensions share may be mounted only as itself, never a subfolder. Together with the grant-time rule above, this holds in either install order (R2 from review 2).
  • A refused install leaves the earlier one as it was. Before the copy, the files the copy and the .env writes will touch are backed up (to CROW_HOME/.install-rollback, mode 0700). If the install fails or is refused before its containers start, they are restored and the new files are removed; a fresh install's folder is removed. The restore never writes or deletes through a folder that has become a symlink. Before this, a refused reinstall left the new compose file and code in place, and several early failures deleted an existing install's folder, data and secrets included.
  • Why a folder is not allowed (R3 from review 2): Configure's response carries a warning per saved folder that is still not allowed, with the reason (unchanged value, default or example, floor, nesting, shared with an extension that mounts inside it). Start's refusal names the reason when an allowed folder can't count.
  • Migration: a one-time pass at gateway start, before any request, grandfathers the folders that installed extensions already use, with or without an installed.json record (romm and dozzle on the main instance have none). After that, no grant means no folder.
  • Remediation: a setting that names an outside folder without a grant is listed by needsConfigKeys, so the extension shows Configure with that field. The refusal says "open its Configure and enter this folder, then Start".
  • Docs (en/es, docs/developers/community-stores.md) describe the rule. The table's old "Named volumes only" row is corrected.

Dry run (read-only: compose config in each installed bundle's real folder, the migration computed in memory, nothing written)

~/.crow:

bundle record folders the migration would grant branch check
browser, design-kit, frigate, kokoro-tts, minio, motioneye, phone, workspace yes or no none PASS
romm no ~/roms PASS
dozzle no none refused: Docker socket needs consent (pre-existing: record-less, so no recorded consent; same on main)
ntfy no none refused: a bind inside another read-write bind (pre-existing since #508; same on main)
llamacpp-vulkan-qwen36-35b-a3b (stale April copy) yes the model cache refused: ipc: host (pre-existing; never started from here)

~/.crow-r4: browser PASS.

The model bundles' LLM_CACHE (passed as operatorRoots by item 30 A's orchestrator check) passes the root floor.

Tests

  • tests/folder-grants.test.js (new; stubbed up/pull, real compose config) covers:
    • the migration (a record-less bundle keeps starting);
    • an owner install grant, then Start;
    • refusals before any up for a request without the owner, a manifest default, an example value and a secret key;
    • a peer Configure (403, nothing written);
    • an owner Configure grant and its removal;
    • a hand edit refused, with Configure then listing the key;
    • a reinstall restoring the kept value;
    • nesting across extensions;
    • grantCandidates filters.
  • tests/compose-guard.test.js covers:
    • outside folders refused unless granted, including siblings, parents, symlinks out and symlinked roots;
    • fixed entries;
    • denied places refused even when granted;
    • roots that fail the floor, are too shallow or nest.
  • tests/folder-grants.test.js also covers:
    • an unchanged hand-edited value that is not granted, and a changed one that is;
    • a save that keeps an existing grant;
    • a reinstall repeating the .env's own value;
    • a refused reinstall restoring compose, .env and code with data kept;
    • a refused fresh install leaving nothing;
    • a refusal after the security check keeping the old grant;
    • the rollback never following a swapped-in symlink;
    • a shared folder in both install orders.
  • Full suite (npm test, on origin/main 9d36c4e): 9956 tests, 9934 pass, 0 fail, 22 skipped.

Rulings (cost if wrong)

  • An operator who hand-edits a folder into .env, or installs with scripts/crow, is refused at Start until they enter it in Configure; the Configure button then appears.
  • An AI driving a logged-in local dashboard counts as the owner. This residual is accepted.
  • A bundle whose default is an outside folder can never get it granted by clicking through, at install or in Configure. Neither can a value already in the .env. The owner must type a different spelling (/srv/media/ works where the value is /srv/media), and Configure's warning says so. An explicit "Allow this folder" confirmation would be nicer UX (follow-up).
  • A corrupt grants store fails closed: no grants, no second migration, and it is never overwritten.

Known gaps (final review, Minor)

  • node_modules is not restored: a required-npm extension whose npm step fails comes back without its dependencies (before, its whole folder was deleted).
  • Keychain and retained-copy writes made before the refusal are not rolled back.
  • A rollback backup left by a gateway crash mid-install is not swept at boot. It holds .env copies at 0700.
  • A deleted grant store runs the one-time migration again.
  • An install and a Configure of the same extension at the same moment can race.
  • The cp of the new files can still follow a symlink a container swapped into the bundle folder (as on main); only the rollback refuses to.

Not in this PR / follow-ups

  • Some compose-up paths still skip the shared check:
    • companion's settings section (recreateCompanion);
    • the browser panel and its crow_browser_launch MCP tool;
    • Docker restart policies.
  • The keychain's localSession predicate accepts peer sign-in sessions (the same gap; pre-existing).
  • dozzle and ntfy on the main instance are refused at Start today (see above).

Deploy

Gateway code only (compose-guard.js, folder-grants.js, routes/bundles.js, bundles-config.js, dashboard/local-session.js) and docs. Units: crow-gateway, crow-r4-gateway (same checkout); the migration runs at their next start. No bundle changes, no container restarts, no model window.

https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

…owner allowed

The install security check was an allowlist inside Crow's home but a denylist outside it:
any other host folder could be mounted read-write, whoever chose it (a bundle default, an
.env.example, a peer instance through Configure). Now, outside Crow's home and data folder,
only these pass:
- fixed entries: /etc/localtime read-only; /proc, /sys and the Docker socket read-only with
  consent (unchanged rules);
- folders the owner allowed (folder grants, CROW_HOME/folder-grants.json, written only by
  the gateway). A grant is made at install or Configure from this machine's own dashboard
  (a local password session: never a signed peer request, never a peer sign-in session),
  only for a value the owner typed for a setting the compose file uses (never the
  manifest's default or example, the .env.example value, a retained value, or a secret
  key). It is key + folder: it counts only while that setting still names the folder.
- Even an allowed folder must pass the floor (system folders, PATH folders, the checkout,
  home dot-folders and ~/bin, a whole home, a git checkout, anything containing Crow's
  home), be two levels deep, and not nest with another extension's allowed folder.
A peer's Configure that sets a folder outside Crow's home is refused (403). A one-time
migration at gateway start grandfathers the folders installed extensions already use
(with or without an installed.json record). An unallowed folder shows its setting in
Configure, so the owner can enter it. Docs (en/es) describe the rule.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ed folders, floor at grant time)

Code review 1 of the host-path allowlist:
- A paired instance can't change a folder setting at all: any value Compose could resolve
  to a path (/, ~, ., $) is refused (a relative ../ or ${X} reached allowed folders).
- When two extensions share an allowed folder, only that folder itself may be mounted,
  never a subfolder (the other extension's container could swap it for a symlink).
- Grants are made only for keys the compose file uses as a mount source, only for a folder
  that passes the floor, is two levels deep and nests with no other extension's; other
  extensions' folders that fail the floor block no one.
- "Outside Crow's home" is decided by where a path really leads (a CROW_HOME/media link to
  a drive); Configure offers a key whose grant is missing or unusable, and a Configure save
  counts a default value (the install prefill does not).
- A corrupt grants store fails closed and never re-runs the migration; a migration write
  error never turns into a 500; a refused reinstall keeps the earlier folder's .env.
- Tests: the real owner check (local session vs peer sign-in vs expired vs signed request),
  uninstall + retained-value reinstall, the symlinked media folder, a corrupt store.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ower non-owner rule that also covers install

Re-review:
- N1: Configure no longer takes a manifest default or example as the owner's choice (the
  form prefills and sends it), so a hostile bundle's default folder is never allowed by a
  Save made for another field.
- N2: the non-owner rule refuses only values Compose could turn into a host path outside
  the bundle (absolute or ~/, a ../ segment, an interpolation; $$ is a literal $). URLs,
  time zones, tokens and ./sub stay settable by peers. The same rule now applies at
  install, so a peer sign-in session can't use a reinstall to point a setting elsewhere.
- A corrupt grants store is never overwritten by a save.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…sed install puts the earlier one back

Final security pass on follow-up B:
- Configure grants (and drops) only keys this save changes, compared with
  the .env before the save; install skips values the earlier install's
  .env already holds. A value something else wrote to the .env (a hand
  edit, a CLI install, a container that can write it) is never granted by
  an unrelated Save. grantCandidates has no allowDefaults escape any more.
- A refused or failed install, before its pre-up checks pass, restores the
  files the copy and the .env writes touched from a backup taken before
  the copy (a fresh install's folder is removed). Earlier, a refused
  reinstall left the new compose file and code in place, and several
  early failures deleted an existing install's folder.
- A folder another extension holds is not granted while that extension
  mounts a folder inside it (its compose model is read; unreadable
  refuses), so a shared folder is safe in either install order.
- Configure's response says which saved folders are still not allowed and
  why, and Start's refusal names the reason when an allowed folder can't
  count (shared, floor, nesting).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…e rollback never follows a link

Re-review fold-in:
- An install's folder grants are recorded after the ownership and
  precreate checks too, so a reinstall refused there keeps its earlier
  grant along with its restored .env.
- The rollback skips any path whose parent inside the bundle folder is
  no longer a real directory: a container with the folder mounted
  read-write could have swapped one for a symlink since the snapshot, and
  the restore would then delete or overwrite files on the host.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
@kh0pper
kh0pper merged commit 4c92f64 into main Oct 19, 2026
4 checks passed
kh0pper added a commit that referenced this pull request Oct 21, 2026
…ass the check; operatorRoots now count (#524)

Stacked on #509, so the test's ipc: host exception for the 35B is dead and is removed: all
model bundles in the repo must pass the install security check as the orchestrator runs it.
The preflight comment said the check ignores ctx.operatorRoots; since #524 the host-path
allowlist uses them (a mount outside Crow's home must sit under one, and the floor applies).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
kh0pper added a commit that referenced this pull request Oct 26, 2026
…ass the check; operatorRoots now count (#524)

Stacked on #509, so the test's ipc: host exception for the 35B is dead and is removed: all
model bundles in the repo must pass the install security check as the orchestrator runs it.
The preflight comment said the check ignores ctx.operatorRoots; since #524 the host-path
allowlist uses them (a mount outside Crow's home must sit under one, and the floor applies).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
kh0pper added a commit that referenced this pull request Oct 26, 2026
…ll security check; 4b, embed and the CUDA model bundles drop ipc: host (#543)

* fix(models): the gpu-orchestrator's compose up goes through the install security check

- bundleUp/bundleStop run only model bundles (repo manifest inference: true), with the
  gateway's allowlisted compose env (never its secrets or COMPOSE_*), and every up is
  checked by the shared model check first, on exactly the invocation up runs (-f, cwd,
  env). The check runs before any mutex sibling is evicted, so a refused start never
  costs the box its resident model. A refusal is a ComposeGuardError; nothing starts.
- What model bundles need is declared, not loopholed: manifest host_grants (gpu,
  shm_size, env_files, path_env), read from the repo manifest only, never for community
  bundles. GPU devices, gpus, GPU reservations, runtime nvidia and NVIDIA_* env need
  host_grants.gpu; /dev/shm above 1 GiB needs host_grants.shm_size; env_file outside the
  bundle only for a declared, existing file in ~/.crow/env (inference: true no longer
  opens any host file).
- 4b, embed and vllm-cuda-{embed,rerank,vision} drop ipc: host (their private /dev/shm
  is shm_size). The 35B's is dropped by its own PR; this one must merge after it.
- composeEnv moves to compose-guard.js (routes keep their wrapper); loadComposeModel
  takes the caller's -f file and cwd.
- Grants and versions: 4b, embed, vllm-cuda-* 1.0.1; 35B 1.0.2 (grants only); sdxl,
  vllm 1.0.2; ollama, localai 1.0.1. Docs (en/es) list host_grants.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(models): NVIDIA_* in a bundle's env_file needs the gpu grant too; clearer refusal wording

Code review I1: the model is built with --no-env-resolution, so NVIDIA_VISIBLE_DEVICES /
NVIDIA_DRIVER_CAPABILITIES in an env_file never showed up in it. Each env_file the check
admits is now read for them. A refused stop says "stop"; comments no longer name hosts and
say that operatorRoots relaxes nothing until the host-path allowlist uses it.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(models): read an env_file for NVIDIA_* only after it is admitted, and only a small regular file

Re-review N1: the NVIDIA_* scan opened every env_file path before the outside-path
refusal, so env_file: /dev/zero (or a FIFO) could block and crash the gateway. The scan
now runs inside the admission loop, after the path is accepted, and reads only a regular
file of at most 1 MiB (anything else is refused). N2: it matches the KEY: value form
Compose accepts, and format: raw keeps quotes literal.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(models): env_file checks fail closed: no dotenv parsing, no missing file, no container-writable file

Final security pass on the install check's env_file handling:
- Any NVIDIA_ mention in an admitted env_file (any case, separator, quoting
  or line ending) needs the gpu grant. The line regex it replaces could
  disagree with Compose's dotenv parser (a lone CR, a NEL prefix).
- The file is stat'ed before it is opened (a device or FIFO is never
  opened), opened non-blocking, checked to be the same regular file, and
  read up to 1 MiB from the descriptor.
- A missing env_file is refused unless it is marked required: false.
- An env_file under a read-write bind or a bind-device volume of the same
  project is refused: a container could swap it before Compose reads it.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(models): with the 35B's ipc: host gone, every model bundle must pass the check; operatorRoots now count (#524)

Stacked on #509, so the test's ipc: host exception for the 35B is dead and is removed: all
model bundles in the repo must pass the install security check as the orchestrator runs it.
The preflight comment said the check ignores ctx.operatorRoots; since #524 the host-path
allowlist uses them (a mount outside Crow's home must sit under one, and the floor applies).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(models): the vision, Heretic and Unsloth bundles declare their GPU and shm host grants

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