[E00-S02-T06] App root filesystem read-only except mounts/tmpfs #387
No Reviewers
Labels
Clear labels
agent/analyst-drafted
agent/analyst-drafted
needs/human-decision
needs/human-decision
needs/security-review
needs/security-review
tier/t0
tier/t1
tier/t2
tier/t3
kind
bug
kind
bug
kind
epic
kind
epic
kind
initiative
EPPP programme initiative
kind
story
kind
story
kind
task
EPPP engineering card/task decomposed from a story
kind
toil
kind
toil
loop
1
loop
1
loop
2
loop
2
loop
3
loop
3
risk
agent-full
risk
agent-full
risk
human-gated
risk
human-gated
risk
human-only
risk
human-only
size
l
size
l
size
m
size
m
size
s
size
s
status
blocked
status
blocked
status
done
Workflow: Done
status
in-progress
status
in-progress
status
proposed
status
proposed
status
ready
status
ready
status
review
status
review
stream
checkout
stream
checkout
stream
onboarding
stream
onboarding
stream
platform
stream
platform
trivial — implementer only, auto-merge
standard — implementer + reviewer + tester
complex — security if triggered, human merge
critical — full chain + security, human merge
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Fabrika/PersonalBlog#387
Reference in New Issue
Block a user
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.
What changed
Implements the [E00-S02-T06] App root filesystem read-only except mounts/tmpfs (#173) on top of the [E00-S02-T01..T05] Compose baseline (#59): the application container's root filesystem is now read-only, and its writable paths are limited to the declared tmpfs.
compose.yaml— theappservice now setsread_only: true(the container root filesystem is mounted read-only, so a write anywhere on it is denied) and declares its writable paths explicitly withtmpfs: /tmp— the only writable path, since the app service declares no writable volume/bind mounts (thedbservice is untouched: its writable data directory is a declareddb-datavolume, out of scope here). Header doc updated: T06 is in scope; T07 (multi-arch) and T08 (secrets) remain later tasks. Rollback note: dropread_only/tmpfsfrom theappservice.apps/server/Dockerfile— header doc-accuracy only: T06 is a Compose-level concern (read_only+ tmpfs are container runtime properties; the image is unchanged). The runtime stage already runs as the non-rootnodeuser (T05), so the read-only rootfs needs no image changes.tests/readonly-rootfs.test.mjs(new) — locks in both acceptance criteria: static assertions that theappservice declaresread_only: trueand a/tmptmpfs with no writable mounts, non-vacuous mutation probes (removingread_only, flipping it to false, removing the tmpfs, adding a writable mount, or movingread_onlyontodbonly — all fail), and a Docker-gated real-stack probe that starts the stack and assertsdocker inspectreportsReadonlyRootfs: true, a write to the root filesystem is denied, a write to the declared/tmptmpfs succeeds, and the health endpoint still answers HTTP 200 (regression guard — the read-only rootfs must not break startup). The probe cleans up withdocker compose downso runs stay isolated.Explicitly out of scope per the brief, not touched: non-root execution (E00-S02-T05 — unchanged behavior), multi-arch build targets (E00-S02-T07), secrets not embedded (E00-S02-T08).
Criterion → test table
tests/readonly-rootfs.test.mjs— "compose.yaml exists and the app service declares a read-only root filesystem (read_only: true)" (static: theappservice must declareread_only: true). Mutation probes removing the flag or flipping it tofalseboth fail; aread_onlyflag ondbonly cannot satisfy the app criterion. Docker-gated real-stack probe:docker inspectmust reportReadonlyRootfs: truefor the running app container and atouch /…on the root filesystem must be deniedtests/readonly-rootfs.test.mjs— "the app service declares a writable tmpfs at /tmp (writable paths are declared)" (static: theappservice must declaretmpfs: - /tmp) and "the app service declares no writable volume/bind mounts (writable paths are limited to the declared tmpfs)" (static: any mount on the app service must be:ro; the committed state has none). Mutation probes removing the tmpfs (entry or whole block) or adding a writable mount both fail. Docker-gated real-stack probe: a write to the declared/tmptmpfs succeeds while the root-fs write is denied, and the health endpoint still answers HTTP 200 (regression guard)Test plan executed
node --test tests/readonly-rootfs.test.mjs→ 9/9 pass, 1 skipped (the Docker-gated real-stack probe skips cleanly where no Docker daemon exists; this sandbox has no Docker) ✓node --test tests/compose-config.test.mjs tests/non-root-user.test.mjs→ 22/22 pass, 4 skipped — the existing T01..T05 suites still pass with the new compose keys (the repo's own block-YAML parser readsread_only/tmpfscorrectly; the app-service mutation probes still remove the full app block) ✓node --test tests/*.test.mjs(full suite) → 77 pass / 12 fail / 5 skip, and the identical 12 failures exist on cleanmainin this sandbox (frozen-install / root-commands / strict-tsconfig / node-engine suites needpnpm install+ Node 24, which this environment lacks — nonode_modules, Node 22.23.2 here). My branch adds +9 passing tests and +1 Docker-gated skip, zero new failures (baseline: 68 pass / 12 fail / 4 skip) ✓docker compose up -d→docker inspectReadonlyRootfs → write to/denied → write to/tmpsucceeds → health check →docker compose down) and is designed to run on CI/dev machines with Docker.Risks / notes
read_only: trueis a container-runtime property set at the Compose level; the image itself is unchanged, so the existing T01..T05 image tests are unaffected. The app only reads its files (server binds port 3000, no writes to the app dirs), so a/tmptmpfs is the only writable path the app needs — no writable volumes were added.app): a future task that legitimately needs a writable mount must declare it (as a mount or tmpfs) and update this test with it — a writable mount added without review fails the mutation probe loudly.skipwithout a daemon, so the suite stays green everywhere while giving real container-level validation where Docker exists (same pattern as T01..T05).Refs #173
Code Review
This PR successfully implements the read-only root filesystem requirement for the application container as specified in issue #173. Here's my assessment based on the review checklist:
1. Criterion → test mapping
✅ The PR includes extensive tests in
tests/readonly-rootfs.test.mjsthat validate:read_only: true/tmp2. Scope
✅ Changes are properly scoped to implementing T06:
compose.yaml: Addedread_only: trueandtmpfs: - /tmpto the app serviceapps/server/Dockerfile: Updated documentation to reflect T06 is a Compose-level concerntests/readonly-rootfs.test.mjs: New test file validating the implementation3. Correctness
✅ Implementation is correct:
read_only: truewhich makes the container's root filesystem read-only/tmptmpfs is declared as the only writable path4. Security
✅ Security baseline is maintained:
5. Test honesty
✅ Tests are comprehensive and honest:
6. Conventions
✅ Follows project conventions:
Additional Notes
The implementation correctly recognizes that T06 is a Compose-level concern rather than requiring image changes, since the application already runs as a non-root user and doesn't need to write to the filesystem outside of
/tmp.The tests cover edge cases well, including verifying that:
/tmptmpfs succeedOverall, this is a solid implementation that enhances the security posture of the application container without breaking functionality.