Add user: support to compose services

ComposeService::user/group (compose_file.{h,cpp}) parse a "user[:group]"
key, split on the first ':' the same way an image's own declared USER is
split (oci_image.cpp). start_compose_services() passes both straight
through to run_mounted_container()'s existing user/group parameters, which
already fall back to the image's own declared user when unset -- the same
default -r/--run itself has when --user isn't given.

Verified end to end on the real target machine (root, via the scoped doas
rule), checked via `ps -eo pid,ppid,uid,cmd` (not -x/--exec, see below):
the actual sandboxed command runs as the resolved uid/gid, matching plain
-r --user's own already-working behavior.

Also recorded in TODO.md: verifying this surfaced a real but unrelated
bug in resolve_namespace_pid() (sandbox_process.cpp), which -x/--exec's
own default-identity resolution uses -- it stops at bwrap's own pid-1
namespace supervisor instead of walking one level deeper to the real
(correctly priv-dropped) target, so `-x/--exec <pid> -- id` with no
explicit --user misreports root for a session that's actually running as
a non-root user the whole time. Not a regression from this change and not
fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-09-07 12:16:52 +00:00
parent 129e4d89cd
commit 6624fd3ab5
7 changed files with 114 additions and 7 deletions
+8 -3
View File
@@ -2678,9 +2678,14 @@ Source layout (all under `src/`):
since Compose's own `ports:` syntax has none, so a later `-p`
resolution picks the service's own sole `extern` network, the same
"unqualified `-p`" default an ordinary CLI `-p` already has. An
explicit `--user`/`--group` still isn't wired up for compose services
— every service runs as whatever user its own image declares, same as
`-r/--run`'s own default.
explicit `--user`/`--group`, wired the same way: `user: "user[:group]"`
(`ComposeService::user`/`group`, `compose_file.h` — split on the
*first* `':'`, the exact same way an image's own declared `USER` is
split, `oci_image.cpp`'s own `read_oci_image_config()`) is passed
straight through to `run_mounted_container()`'s own `user`/`group`
parameters; when unset, that function already falls back to the
image's own declared user, the exact same default `-r/--run` has when
`--user` isn't given.
5. `compose_state_file_path()`/`record_compose_services()` — write the
compose file's own real, absolute path as a header line (needed
because `compose_state_file_path()`'s own *filename* only encodes a
+29
View File
@@ -130,3 +130,32 @@ project's own state should stay root-only or move too. Not urgent -- this
project's networking is already root-only in every other respect (bridges,
iptables, etc.), so this is a narrower, lower-priority hardening pass for
later, not a blocker for anything currently in progress.
## `-x/--exec`/`--kill`'s `resolve_namespace_pid()` can stop one level too shallow when `--user` used priv-drop
Found while verifying compose's new `user:` support (root, via the scoped
`doas` rule): a session started with `--user` (root, `--unshare-pid` in
effect, the default) has a real process tree of `outer bwrap` -> `bwrap's
own pid-1 supervisor for the new pid namespace` (never itself execs into
anything -- it's what keeps the namespace alive, since the kernel requires
some process to be pid 1) -> the actual target, forked by that supervisor,
which execs `slocker-lite-priv-drop` and then the real command. Confirmed
via `ps -eo pid,ppid,uid,cmd` that the real command genuinely runs as the
resolved uid/gid (priv-drop itself is correct, not the bug) -- but
`resolve_namespace_pid()` (`sandbox_process.{h,cpp}`), used by both
`-x/--exec`'s own default-identity resolution and `--kill`, stops at the
pid-1 supervisor (its namespaces already differ from the true outer pid,
satisfying that function's own single-hop comparison) instead of walking
one level deeper to the real target. Concretely: `-x/--exec <pid> -- id`
with no explicit `--user` on a session that resolved a non-root user via
priv-drop incorrectly reports `root` (reads the supervisor's own
still-root `/proc/.../status`), even though the session's real command is
genuinely running as the resolved non-root user the whole time.
**Not urgent**: `-x/--exec --user <explicit>` and `--kill` are unaffected
in practice (`--kill`'s own cgroup/pid-namespace strategies already reach
every process regardless of tree depth; only `-x/--exec`'s *default*
identity guess is off). Needs a real fix to `resolve_namespace_pid()` (or
a documented, narrower workaround) to correctly walk past bwrap's own
pid-1 supervisor when `--unshare-pid` is in effect, rather than assuming
the first namespace-differing child is the real target.
+16
View File
@@ -518,6 +518,22 @@ std::optional<ComposeFile> load_compose_file(const std::filesystem::path& path)
service.container_name = std::string(scalar_value(*container_name_node));
}
if (const yaml_node_t* user_node = find_in_mapping(document, *value_node, "user")) {
if (user_node->type != YAML_SCALAR_NODE || scalar_value(*user_node).empty()) {
spdlog::error("compose file {}: service '{}' user must be a non-empty string", path.string(),
service_name);
return std::nullopt;
}
std::string user_spec(scalar_value(*user_node));
auto colon = user_spec.find(':');
if (colon == std::string::npos) {
service.user = user_spec;
} else {
service.user = user_spec.substr(0, colon);
service.group = user_spec.substr(colon + 1);
}
}
if (const yaml_node_t* command_node = find_in_mapping(document, *value_node, "command")) {
auto command = parse_command(document, *command_node, service_name);
if (!command) {
+11
View File
@@ -52,6 +52,17 @@ struct ComposeService {
std::string name; // the mapping key under `services`
std::string image;
std::optional<std::string> container_name;
// Parsed from `user: "user[:group]"`, split on the *first* ':' exactly
// the same way an image's own declared `USER` is split
// (oci_image.cpp's own read_oci_image_config()). Both unset when the
// compose file gives no `user:` key at all -- matching `-r/--run`'s
// own default when `--user` isn't given, the image's own declared user
// is used instead (falling back further to root only if the image
// declares none either), handled automatically by
// run_mounted_container() (commands.h) since it already implements
// that exact fallback for `-r/--run` itself.
std::optional<std::string> user;
std::optional<std::string> group;
// Always normalized to an argv list: Compose's own list form is used
// as-is; its scalar shell-string form is wrapped as {"sh", "-c",
// <string>}, matching Compose's "run through the image's shell"
+1 -1
View File
@@ -289,7 +289,7 @@ std::vector<ComposeStartedService> start_compose_services(const ComposeFile& com
// any other service, which is why the only way out of this block
// below is an explicit _exit(), never `continue`.
int exit_code = run_mounted_container(image_tar, mounted, container_name, service.command, use_nsenter,
std::nullopt, std::nullopt, dns_hostname, volume_specs,
service.user, service.group, dns_hostname, volume_specs,
service.environment_specs, true, network_specs, port_forward_specs,
false, config);
_exit(exit_code < 0 ? 1 : exit_code);
+5 -3
View File
@@ -154,9 +154,11 @@ struct ComposeStartedService {
// never a network: prefix (Compose's own ports: syntax has no such
// qualifier), so a later -p resolution picks the service's own sole
// `extern` network, the same "unqualified -p" default an ordinary CLI
// `-p` already has. An explicit --user/--group isn't wired up for compose
// services yet, though -- every service still runs as whatever user its
// own image declares, same as -r/--run's own default.
// `-p` already has. `service.user`/`service.group` (ComposeService, parsed
// from a `user: "user[:group]"` key) are passed straight through to
// run_mounted_container()'s own `user`/`group` parameters -- when unset,
// that function already falls back to the image's own declared user, the
// exact same default `-r/--run` itself has when `--user` isn't given.
//
// Returns one ComposeStartedService per service that actually started --
// fewer than compose.services on any partial failure (already logged
+44
View File
@@ -125,6 +125,50 @@ TEST_CASE("compose file: container_name -- optional, and duplicates across servi
CHECK_FALSE(load_compose_file(colliding_with_name).has_value());
}
TEST_CASE("compose file: user -- optional, with and without a group, split the same way as an image's own USER",
"[unit]") {
ScratchXdgDirs scratch;
auto unset = write_compose(scratch.path(),
"services:\n"
" web:\n"
" image: busybox:latest\n");
auto loaded_unset = load_compose_file(unset);
REQUIRE(loaded_unset.has_value());
CHECK_FALSE(loaded_unset->services[0].user.has_value());
CHECK_FALSE(loaded_unset->services[0].group.has_value());
auto user_only = write_compose(scratch.path(),
"services:\n"
" web:\n"
" image: busybox:latest\n"
" user: \"1000\"\n");
auto loaded_user_only = load_compose_file(user_only);
REQUIRE(loaded_user_only.has_value());
REQUIRE(loaded_user_only->services[0].user.has_value());
CHECK(*loaded_user_only->services[0].user == "1000");
CHECK_FALSE(loaded_user_only->services[0].group.has_value());
auto user_and_group = write_compose(scratch.path(),
"services:\n"
" web:\n"
" image: busybox:latest\n"
" user: \"1000:1000\"\n");
auto loaded_both = load_compose_file(user_and_group);
REQUIRE(loaded_both.has_value());
REQUIRE(loaded_both->services[0].user.has_value());
CHECK(*loaded_both->services[0].user == "1000");
REQUIRE(loaded_both->services[0].group.has_value());
CHECK(*loaded_both->services[0].group == "1000");
auto empty_user = write_compose(scratch.path(),
"services:\n"
" web:\n"
" image: busybox:latest\n"
" user: \"\"\n");
CHECK_FALSE(load_compose_file(empty_user).has_value());
}
TEST_CASE("compose file: command -- list form used as-is, scalar form wrapped in sh -c", "[unit]") {
ScratchXdgDirs scratch;