Depends on #1141 (branch is rebased on it; merge #1141 first and this reduces to the two docker commits).
The gateway image had four runtime/build defects, found while auditing what a full framework install needs underneath the webUI:
No git in the runner stage — WorkspaceService shells out to git at runtime (apps/gateway/src/workspace/workspace.service.ts), so every workspace verb would fail in the container.
No workspace directory — workspaces root at $MOSAIC_ROOT/.workspaces with a /opt/mosaic default; the image now creates it and sets MOSAIC_ROOT explicitly. Mount a volume over /opt/mosaic to persist workspaces across restarts (compose templates should do this — noted in the PR so it isn't lost).
scripts/ missing from the builder's layer-cached install — the root prepare script runs scripts/install-hooks.mjs, so pnpm install --frozen-lockfile died with MODULE_NOT_FOUND before anything else could happen (same fix applied to the web image in #1141).
EXPOSE 4000 but main.ts defaults to 14242 (HANDOFF blocker 4).
Verified:docker build -f docker/gateway.Dockerfile . completes from a clean context on this branch, and the runner container shows git version 2.52.0, MOSAIC_ROOT=/opt/mosaic, /opt/mosaic/.workspaces present, and dist/main.js in place. As far as we know this is the first successful gateway image build on next.
Depends on #1141 (branch is rebased on it; merge #1141 first and this reduces to the two docker commits).
The gateway image had four runtime/build defects, found while auditing what a full framework install needs underneath the webUI:
1. **No `git` in the runner stage** — `WorkspaceService` shells out to git at runtime (`apps/gateway/src/workspace/workspace.service.ts`), so every workspace verb would fail in the container.
2. **No workspace directory** — workspaces root at `$MOSAIC_ROOT/.workspaces` with a `/opt/mosaic` default; the image now creates it and sets `MOSAIC_ROOT` explicitly. Mount a volume over `/opt/mosaic` to persist workspaces across restarts (compose templates should do this — noted in the PR so it isn't lost).
3. **`scripts/` missing from the builder's layer-cached install** — the root `prepare` script runs `scripts/install-hooks.mjs`, so `pnpm install --frozen-lockfile` died with MODULE_NOT_FOUND before anything else could happen (same fix applied to the web image in #1141).
4. **`EXPOSE 4000`** but `main.ts` defaults to **14242** (HANDOFF blocker 4).
**Verified:** `docker build -f docker/gateway.Dockerfile .` completes from a clean context on this branch, and the runner container shows `git version 2.52.0`, `MOSAIC_ROOT=/opt/mosaic`, `/opt/mosaic/.workspaces` present, and `dist/main.js` in place. As far as we know this is the first successful gateway image build on `next`.
install-hooks.mjs hard-failed (exit 1) in environments without a git
binary — e.g. the docker image builds, which have no git and no repo.
Hook installation is meaningless there; skip with a warning instead.
docker/web.Dockerfile filtered @mosaic/web, but the package is named
@mosaicstack/web, so the image build compiled nothing.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
The layer-cached install copies only manifests and packages/, so the
root prepare script could not be found and pnpm install exited 1
before the git-absent guard could even run.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
WorkspaceService shells out to git at runtime and roots workspaces at
$MOSAIC_ROOT/.workspaces — the runner image had no git binary and no
workspace directory. EXPOSE said 4000 but main.ts defaults to 14242.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
SECURITY-REVIEW DISPOSITION (#1142) — independent re-review (author ≠ reviewer), verdict APPROVE. The [major] RCE finding is FIXED and the fix was independently verified by adversarial probe (not the author's self-report).
Original finding: adding git to the gateway runner image activated a pre-existing unvalidated repoUrl → git clone path — root RCE via git's ext::/file:: helpers and leading-dash argument injection.
Verified closed — three independent defense layers, any one sufficient:
Unconditional service-layer allowlist — assertAllowedRepositoryUrl runs inside WorkspaceService.create() before git is invoked (so a caller bypassing the DTO is still safe). Rejects: anything not matching anchored ^(?:https|git)://, anything starting with -, anything new URL() cannot parse, and anything whose parsed protocol ∉ {https:,git:} or whose hostname is empty.
Hardened git argv — -c protocol.ext.allow=never -c protocol.file.allow=never clone -- <url> . disables the exact ext::/file:: RCE helpers and uses -- so a dash-leading value can never be parsed as an option. Spawned via execFile (argv array, no shell) → no metacharacter surface.
class-validator DTO enforced by the global ValidationPipe at the controller.
Adversarial probe (empirically run through the actual validation logic):
Input
Result
ext::sh -c 'id' (and any ext::)
rejected
-x, --upload-pack=... (leading-dash)
rejected
file:///etc/..., file://
rejected
git@host:path, ssh://… (SSH shorthand)
rejected
hostless https://, git://, https:///
rejected
https:/x, https:x (no //)
rejected
HTTPS://host/… (uppercase)
accepted (benign)
https://u:p@host/… (embedded creds)
accepted (benign)
CRLF-embedded host
rejected / CRLF-stripped (benign)
unicode / javascript:
rejected
Only legitimate https/git URLs are accepted.
[minor] runner-as-root: FIXED — USER node set; /opt/mosaic and /app chowned to node, --chown=node:node on all COPYs; port 14242 unprivileged. [minor] unpinned Alpine git: ACCEPTED-with-rationale — documented risk acceptance; Alpine signed repo is the trust anchor.
Two non-blocking notes: teamId path-safety is pre-existing and out of scope (this commit neither introduces nor touches it); no secrets introduced; scope limited to the four workspace files + Dockerfile (+ specs).
No blockers. Merge-eligible on the security lens.
**SECURITY-REVIEW DISPOSITION (#1142)** — independent re-review (author ≠ reviewer), verdict **APPROVE**. The [major] RCE finding is **FIXED** and the fix was independently verified by adversarial probe (not the author's self-report).
Original finding: adding `git` to the gateway runner image activated a pre-existing unvalidated `repoUrl → git clone` path — root RCE via git's `ext::`/`file::` helpers and leading-dash argument injection.
**Verified closed — three independent defense layers, any one sufficient:**
1. **Unconditional service-layer allowlist** — `assertAllowedRepositoryUrl` runs inside `WorkspaceService.create()` before git is invoked (so a caller bypassing the DTO is still safe). Rejects: anything not matching anchored `^(?:https|git)://`, anything starting with `-`, anything `new URL()` cannot parse, and anything whose parsed protocol ∉ {`https:`,`git:`} or whose hostname is empty.
2. **Hardened git argv** — `-c protocol.ext.allow=never -c protocol.file.allow=never clone -- <url> .` disables the exact `ext::`/`file::` RCE helpers and uses `--` so a dash-leading value can never be parsed as an option. Spawned via `execFile` (argv array, no shell) → no metacharacter surface.
3. **class-validator DTO** enforced by the global `ValidationPipe` at the controller.
**Adversarial probe (empirically run through the actual validation logic):**
| Input | Result |
|---|---|
| `ext::sh -c 'id'` (and any `ext::`) | rejected |
| `-x`, `--upload-pack=...` (leading-dash) | rejected |
| `file:///etc/...`, `file://` | rejected |
| `git@host:path`, `ssh://…` (SSH shorthand) | rejected |
| hostless `https://`, `git://`, `https:///` | rejected |
| `https:/x`, `https:x` (no `//`) | rejected |
| `HTTPS://host/…` (uppercase) | accepted (benign) |
| `https://u:p@host/…` (embedded creds) | accepted (benign) |
| CRLF-embedded host | rejected / CRLF-stripped (benign) |
| unicode / `javascript:` | rejected |
Only legitimate https/git URLs are accepted.
**[minor] runner-as-root: FIXED** — `USER node` set; `/opt/mosaic` and `/app` chowned to `node`, `--chown=node:node` on all COPYs; port 14242 unprivileged.
**[minor] unpinned Alpine git: ACCEPTED-with-rationale** — documented risk acceptance; Alpine signed repo is the trust anchor.
Two non-blocking notes: `teamId` path-safety is pre-existing and out of scope (this commit neither introduces nor touches it); no secrets introduced; scope limited to the four workspace files + Dockerfile (+ specs).
No blockers. Merge-eligible on the security lens.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Depends on #1141 (branch is rebased on it; merge #1141 first and this reduces to the two docker commits).
The gateway image had four runtime/build defects, found while auditing what a full framework install needs underneath the webUI:
gitin the runner stage —WorkspaceServiceshells out to git at runtime (apps/gateway/src/workspace/workspace.service.ts), so every workspace verb would fail in the container.$MOSAIC_ROOT/.workspaceswith a/opt/mosaicdefault; the image now creates it and setsMOSAIC_ROOTexplicitly. Mount a volume over/opt/mosaicto persist workspaces across restarts (compose templates should do this — noted in the PR so it isn't lost).scripts/missing from the builder's layer-cached install — the rootpreparescript runsscripts/install-hooks.mjs, sopnpm install --frozen-lockfiledied with MODULE_NOT_FOUND before anything else could happen (same fix applied to the web image in #1141).EXPOSE 4000butmain.tsdefaults to 14242 (HANDOFF blocker 4).Verified:
docker build -f docker/gateway.Dockerfile .completes from a clean context on this branch, and the runner container showsgit version 2.52.0,MOSAIC_ROOT=/opt/mosaic,/opt/mosaic/.workspacespresent, anddist/main.jsin place. As far as we know this is the first successful gateway image build onnext.CODE-REVIEW DISPOSITION (#1142) — independent reviewer (author ≠ reviewer), verdict FINDINGS (minors only), all dispositioned. Remediation commit
a34e92cfverified against source and re-run gates by the integrator (not author self-report).Independent gate re-run (integrator, box worktree
stack-push, branchfix/gateway-runner-image):Test Files 2 passed (2)/Tests 23 passed (23)Test Files 61 passed | 7 skipped (68)/Tests 667 passed | 17 skipped (684)pnpm --filter @mosaicstack/gateway typecheck→ exit 0pnpm --filter @mosaicstack/gateway lint→ exit 0pnpm format:check→ cleandocker/gateway.Dockerfile), +296/-23 — no scope creep.ci.mosaicstack.dev) is the canonical build path.USER node, artifacts chowned.gitNo blockers. Merge-eligible on the code lens.
SECURITY-REVIEW DISPOSITION (#1142) — independent re-review (author ≠ reviewer), verdict APPROVE. The [major] RCE finding is FIXED and the fix was independently verified by adversarial probe (not the author's self-report).
Original finding: adding
gitto the gateway runner image activated a pre-existing unvalidatedrepoUrl → git clonepath — root RCE via git'sext::/file::helpers and leading-dash argument injection.Verified closed — three independent defense layers, any one sufficient:
assertAllowedRepositoryUrlruns insideWorkspaceService.create()before git is invoked (so a caller bypassing the DTO is still safe). Rejects: anything not matching anchored^(?:https|git)://, anything starting with-, anythingnew URL()cannot parse, and anything whose parsed protocol ∉ {https:,git:} or whose hostname is empty.-c protocol.ext.allow=never -c protocol.file.allow=never clone -- <url> .disables the exactext::/file::RCE helpers and uses--so a dash-leading value can never be parsed as an option. Spawned viaexecFile(argv array, no shell) → no metacharacter surface.ValidationPipeat the controller.Adversarial probe (empirically run through the actual validation logic):
ext::sh -c 'id'(and anyext::)-x,--upload-pack=...(leading-dash)file:///etc/...,file://git@host:path,ssh://…(SSH shorthand)https://,git://,https:///https:/x,https:x(no//)HTTPS://host/…(uppercase)https://u:p@host/…(embedded creds)javascript:Only legitimate https/git URLs are accepted.
[minor] runner-as-root: FIXED —
USER nodeset;/opt/mosaicand/appchowned tonode,--chown=node:nodeon all COPYs; port 14242 unprivileged.[minor] unpinned Alpine git: ACCEPTED-with-rationale — documented risk acceptance; Alpine signed repo is the trust anchor.
Two non-blocking notes:
teamIdpath-safety is pre-existing and out of scope (this commit neither introduces nor touches it); no secrets introduced; scope limited to the four workspace files + Dockerfile (+ specs).No blockers. Merge-eligible on the security lens.