Skip to content

feat(up): support cap_add, cap_drop, tmpfs, shm_size, init and ulimits - #152

Open
Mikimoto wants to merge 9 commits into
Mcrich23:mainfrom
Mikimoto:feat/hardening-keys
Open

Mikimoto wants to merge 9 commits into
Mcrich23:mainfrom
Mikimoto:feat/hardening-keys

Conversation

@Mikimoto

@Mikimoto Mikimoto commented Sep 1, 2026

Copy link
Copy Markdown

feat(up): support cap_add, cap_drop, tmpfs, shm_size, init and ulimits

Summary

Six container-hardening compose keys are currently absent from Service's CodingKeys. Swift's
Codable ignores unknown keys, so today they are dropped with no warning: a compose file that
declares cap_drop: [ALL] and a read-only rootfs with tmpfs mounts runs with the default
capability set and no tmpfs, and nothing in the output says so.

All six have a container run equivalent. This adds them, plus reporting for two things that do
not.

compose key container run
cap_add / cap_drop --cap-add / --cap-drop
tmpfs --mount type=tmpfs,target=…
shm_size --shm-size
init --init
ulimits --ulimit <type>=<soft>[:<hard>]
network_mode (no equivalent — reported)

Why the mapping is a pure function

The mapping lives in ComposeUp.hardeningRunArgs(for:environment:) rather than inline in the
argument builder, and the tests call it directly.

The reason is concrete. The existing tests cover Codable parsing and the already-extracted pure
helpers (clampMemoryLimit, composePortToRunArg, networkRunArg), but runCommandArgs has no
test seam: grep -rn runCommandArgs returns 33 hits in ComposeUp.swift and 0 in Tests/.
That gap is why healthcheck.timeout can be decoded (Healthcheck.swift:48,61,79), asserted on by
two parsing tests, and still never reach waitUntilServiceIsHealthy — no test can see the
difference. Adding six more keys the same way would have reproduced it six more times.

Measured against container 1.0.0, not inferred

Two findings shaped the implementation. Both are reproducible on macOS 27.0 / container 1.0.0.

--tmpfs silently mounts at the literal path when given Compose-style options.

$ container run --rm --tmpfs /run:noexec,nosuid alpine:3 sh -c 'mount | grep tmpfs'
tmpfs on /run:noexec,nosuid type tmpfs (rw,relatime)

There is no error. /run is not mounted, and a directory named /run:noexec,nosuid exists
instead. A read-only container then fails to write /run with a permission error that points
nowhere near the cause. This change therefore uses --mount type=tmpfs exclusively.

--mount type=tmpfs accepts only target, mode and size.

$ container run --rm --mount type=tmpfs,target=/run,noexec alpine:3 true
Error: unknown directive noexec when parsing mount type=tmpfs,target=/run,noexec

$ container run --rm --mount type=tmpfs,target=/run,mode=0755,uid=70,gid=70 alpine:3 true
Error: unknown directive uid when parsing mount type=tmpfs,target=/run,mode=0755,uid=70,gid=70

So noexec, nosuid, nodev, uid and gid cannot be expressed. They are dropped — but
reported, never silently:

Note: Service 'patroni1' tmpfs '/run/postgresql': `container run` accepts only target, mode and
size; dropped noexec,nosuid,uid=70,gid=70.
Warning: Service 'patroni1' tmpfs '/run/postgresql' requested uid/gid ownership, which
`container run` cannot express. The mount will be owned by root, so a non-root container cannot
write to it unless the mode is world-writable.

That second warning is not hypothetical. The mount is root-owned, so:

--user 70:70 --mount type=tmpfs,target=/run/postgresql,mode=0755   →  Permission denied
--user 70:70 --mount type=tmpfs,target=/run/postgresql,mode=0777   →  writes fine

A service running as a non-root user with a read-only rootfs — PostgreSQL putting its socket in
/run/postgresql is the usual case — will fail to start, and the reason is worth one line of
output.

network_mode

container run has no way to express "no network": a container started without --network still
joins the default network and gets an address. network_mode is therefore parsed only so it can be
reported, and produces no run arguments.

Capability ordering is deliberately not asserted

--cap-add and --cap-drop are collected into two separate arrays (Flags.swift:231-241) and the
effective set is computed in RuntimeService.effectiveCapabilities — cap_drop: ALL clears the
base, adds are applied, then individual drops are removed. The order the flags appear in on the
command line carries no meaning, so the tests assert that every declared capability reaches its
flag rather than asserting a sequence.

ulimits

Compose allows both nofile: 65535 and nofile: {soft: 20000, hard: 40000}. container run takes
<type>=<soft>[:<hard>] (Parser.rlimit) and its type names match Compose's exactly, so both forms
map directly. They are normalised through a small UlimitValue decoder.

An entry that cannot be read throws rather than nilling the map. Nilling would drop the sibling
entries with it, which is the same silent-loss shape this change set exists to remove. tmpfs does
the same for a value that is neither a list nor a string.

Tests

Two suites:

  • HardeningArgsTests — per-key parsing and flag mapping, including the ulimits long form,
    whitespace after a comma in tmpfs options, ${VAR} interpolation, and the two throwing paths.
  • HardeningComposeIntegrationTests — the same keys through a whole compose document that shares
    them via a YAML anchor and merge keys. A parser that failed to resolve <<: would pass every
    per-key test and fail here.

Verification on macOS 27.0 / Swift 6.4, branched from main @ 6e6aaf0:

check result
swift build 0 errors
swift test (static suites) 261 tests in 23 suites passed (baseline on main is 236 in 21)
git diff main..HEAD --check no output

Every new behaviour was mutation-checked: reverting it turns the covering test red. The capability
ordering was the one case where a test stayed green under mutation for a good reason, which is what
led to dropping that assertion.

Notes and limits

  • Terminating a container exec on the host does not guarantee the guest process is reaped; not
    introduced here, but relevant to anything built on these flags.
  • size= is passed through as written. container interprets it in MiB, so a byte-valued
    size=1000000 truncates to 0. Compose's own units are not translated; left as-is to avoid
    guessing at intent.
  • security_opt and logging remain unsupported: container inspect's configuration schema has
    no field for the former, and there is no log-driver concept for the latter.
  • stop_grace_period is deliberately untouched — feat: add/support stop_grace_period #150 is already open for it.

@Mcrich23

Mcrich23 commented Sep 9, 2026

Copy link
Copy Markdown
Owner

I like adding support for these options, and the warnings help explain the remaining limitations. Can you add dynamic tests confirming the capabilities, tmpfs mounts, and limits are actually applied? For network_mode: none, I think we should fail with a clear unsupported message rather than start the container with networking enabled.

@Mikimoto

Copy link
Copy Markdown
Author

I like adding support for these options, and the warnings help explain the remaining limitations. Can you add dynamic tests confirming the capabilities, tmpfs mounts, and limits are actually applied? For network_mode: none, I think we should fail with a clear unsupported message rather than start the container with networking enabled.

Thanks. Both are actionable.

Dynamic tests. Agreed these need to assert the options actually land, not just that the right flags were assembled. All four are observable from inside the container via container exec, which is the same mechanism the existing DNS dynamic suite uses:

  • cap_add / cap_drop → CapEff / CapBnd in /proc/self/status
  • tmpfs → the mount entry in /proc/mounts, including its options
  • shm_size → the /dev/shm size in /proc/mounts
  • ulimits → /proc/self/limits
  • init → whether PID 1 is the init shim rather than the service

So each one gets an assertion against what the kernel reports, not against the argv we built.

network_mode. You're right, and I'd like to go slightly further than none if you agree. Today any network_mode prints a note and the container joins the default network. For none that is the bad case — the user asked for no networking and silently got some, which is strictly less isolated than requested. But host, service: and container: are also not honoured; they just fail as "doesn't behave as configured" rather than as a safety downgrade.

My inclination is to fail for every network_mode value container run cannot express, with the value named in the message, and keep bridge passing since that is effectively what you get. That turns all of them into an actionable error instead of one being an error and three staying notes. If you'd rather scope this PR to none only and leave the rest as warnings, that's fine too — just say which and I'll match it.

…laim

Three problems found by a fresh-context review of this branch, all verified
against the installed apple/container sources rather than inferred:

ulimits: the decoder tried [String: String] then [String: Int] and fell back to
nil. Compose also allows a {soft, hard} pair, which matched neither, so a file
using the long form silently lost its whole ulimits map including any sibling
entries in short form. container run --ulimit takes <type>=<soft>[:<hard>]
(Parser.rlimit) and its type names match Compose's exactly, so the long form is
directly expressible. Both forms now normalise through UlimitValue, and an entry
that cannot be read throws instead of nilling the map.

tmpfs: same silent-nil shape for a value that is neither a list nor a string;
now throws. Options are also trimmed, so "/run:noexec, mode=0755" no longer
drops mode by failing its prefix test.

Capabilities: the comment claimed cap_drop was emitted before cap_add "so that
cap_drop: [ALL] followed by a narrow cap_add behaves as Compose specifies".
That is false. container collects the two flags into separate arrays
(Flags.swift) and computes the effective set in RuntimeService.effectiveCapabilities
- drop-ALL clears the base, adds are applied, individual drops removed - so the
command-line order carries no meaning. Two tests asserted that ordering; they
now assert that every declared capability reaches its flag, which is the real
invariant.

The six new keys also went through the run-args builder without variable
interpolation while every neighbouring key resolved ${VAR}; they now take the
environment and resolve it.
…ke effect

**`network_mode: none` now stops the run.** Every unsupported mode was reported
and the container started anyway on the default network. For `host`,
`service:<name>` and `container:<id>` that is wrong in the direction of "does not
behave as configured". For `none` it is wrong in the other direction: the file
asks for no networking and the container comes up connected, which is less
isolation than was requested and is not something to find out from a note in the
log. `rejectedNetworkMode` is the seam, normalising case and surrounding space so
`None` and `  NONE  ` cannot slip through; the other modes keep their note.

Scoped to `none` deliberately. Widening it to every unsupported mode would change
behaviour for existing compose files, and that is a call for the maintainer, not
a side effect of this PR — the set is one function and one line away.

**The hardening keys are now asserted from inside the container**, against what
the kernel reports rather than against the argv that was assembled. Those are two
different claims, and only the static suite covered the first:

  cap_drop: ALL + cap_add: NET_BIND_SERVICE   CapBnd is exactly 1 << 10 — asserted
                                              as the whole mask, so anything
                                              `cap_drop: ALL` failed to remove
                                              shows up as an extra bit
  tmpfs: /scratch                             present in /proc/mounts as tmpfs
  shm_size: 64M                               /dev/shm carries size=65536k
  ulimits nofile 1234:5678                    both columns in /proc/self/limits
  init: true                                  PID 1 is not the service command

Driven through compose rather than `container run`, so the mapping is what is
under test. Six static tests for the rejection, one dynamic test for the rest.
The multi-line literal kept the continuation lines' indentation, so the error
reached the terminal with runs of spaces inside it. Verified end to end: a
compose file with network_mode: none now exits 1 with the message on one line
and starts no container.
Both predate the change and assert that `network_mode: none` produces a note.
It now throws instead, so the note is deliberately absent and those expectations
were inverted.

`networkModeIsReported` now checks both directions: `none` produces no note
because the throw is the whole report, and `host` — unsupported but survivable —
still does. The document-driven one asserts the rejection seam returns the value
for the `fixer` service rather than looking for a warning that no longer exists.

Static suite back to green: 273 tests in 25 suites.
@Mikimoto

Copy link
Copy Markdown
Author

Pushed, rebased onto current main.

Dynamic tests. Everything is asserted from /proc inside a container compose brought up, not against the argv that was assembled — those are two different claims and only the first was covered:

key asserted as
cap_drop: ALL + cap_add: NET_BIND_SERVICE CapBnd is exactly 1 << 10
tmpfs: /scratch present in /proc/mounts, as tmpfs
shm_size: 64M /dev/shm carries size=65536k
ulimits nofile soft 1234 / hard 5678 both columns in /proc/self/limits
init: true PID 1 is not the service command

The capability check asserts the whole mask rather than "contains", which is what makes cap_drop: ALL observable: anything it failed to remove shows up as an extra bit. Mutation-checked — dropping the --cap-drop mapping gives CapBnd 00000000a80425fb (the full default set) and dropping --ulimit gives 1048576/1048576, so both assertions reject the unmapped case rather than passing on whatever the runtime happened to do. Runs in ~2s.

network_mode: none. Now stops the run. Verified end to end: a compose file with it exits 1, starts no container, and prints

Error: Service 'box' sets network_mode: none, which `container run` cannot express. Starting it
anyway would attach the container to the default network — more connectivity than the compose file
asks for, not less. Remove the key, or run this service under a runtime that supports it.

rejectedNetworkMode is the seam, normalising case and surrounding whitespace so None and NONE cannot slip past. Six static tests cover it.

I scoped it to none only, per your comment rather than the wider version I floated — host, service:<name> and container:<id> keep their note. Widening it changes behaviour for existing compose files and that's your call; it's one line in one function if you want it.

Two existing assertions expected none to produce a note, so they're updated: one now checks both directions (none silent because the throw is the whole report, host still reported), the other asserts the rejection seam instead of a warning that no longer exists.

Static suite: 273 tests / 25 suites green, up from 267 / 24.

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.

2 participants