Repository navigation
fix(bundles): outside Crow's home, extensions mount only folders the owner allowed - #524
Merged
Merged
Conversation
…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
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
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.
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,
~/binand git checkouts were refused, and everything else could be mounted read-write, whoever chose it. That meant a bundle default, a.env.examplevalue, or a paired instance setting a path through Configure and then pressing Start (security review D).What changes
/etc/localtimeread-only;/proc,/sysand the Docker socket read-only with consent (these rules are unchanged);servers/gateway/folder-grants.js, storeCROW_HOME/folder-grants.json, written only by the gateway):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.default/example, the.env.examplevalue, a retained value, or asecretkey's value. The modals prefill defaults, so a default is never taken as a choice..envbefore the save. Install skips a value the earlier install's.envalready 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.~/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../segment, an interpolation;$$is a literal$). Configure and install both answer 403folder_needs_owner. URLs, time zones, tokens and./subvalues still work..envwrites will touch are backed up (toCROW_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.needsConfigKeys, so the extension shows Configure with that field. The refusal says "open its Configure and enter this folder, then Start".docs/developers/community-stores.md) describe the rule. The table's old "Named volumes only" row is corrected.Dry run (read-only:
compose configin each installed bundle's real folder, the migration computed in memory, nothing written)~/.crow:~/romsipc: host(pre-existing; never started from here)~/.crow-r4: browser PASS.The model bundles'
LLM_CACHE(passed asoperatorRootsby item 30 A's orchestrator check) passes the root floor.Tests
tests/folder-grants.test.js(new; stubbed up/pull, realcompose config) covers:grantCandidatesfilters.tests/compose-guard.test.jscovers:tests/folder-grants.test.jsalso covers:.env's own value;.envand code with data kept;npm test, on origin/main 9d36c4e): 9956 tests, 9934 pass, 0 fail, 22 skipped.Rulings (cost if wrong)
.env, or installs withscripts/crow, is refused at Start until they enter it in Configure; the Configure button then appears..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).Known gaps (final review, Minor)
node_modulesis not restored: a required-npm extension whose npm step fails comes back without its dependencies (before, its whole folder was deleted)..envcopies at 0700.Not in this PR / follow-ups
recreateCompanion);crow_browser_launchMCP tool;localSessionpredicate accepts peer sign-in sessions (the same gap; pre-existing).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