Validates the given pid against the same tracked-session liveness check --list-processes/--clean-processes already use, then joins its namespaces via nsenter and runs a command there in the foreground. Two things discovered only by testing against a live session, not assumed up front: - The tracked pid is bwrap's own outer process. It sets up the mount/user namespaces itself, then clone()s the actual sandboxed command into fresh pid/uts/ipc/cgroup namespaces -- clone()'s namespace flags only ever affect the new child, never the caller, so the outer process itself never enters those namespaces at all. exec_in_session() resolves that real child via /proc/<pid>/task/<pid>/children and joins its namespaces instead, falling back to the outer pid if that can't be read. - Rather than nsenter -a (which would hit a known "Invalid argument" failure re-entering an identical namespace -- this project already worked around exactly that once, for the containers-storage mount path), each namespace type is only joined if /proc/<pid>/ns/<type> actually differs from this process's own. nsenter also needs --preserve-credentials, or it tries to setuid/setgid/setgroups to the target's identity, which fails outright against the setgroups-denied unprivileged user namespace bwrap creates whenever -r/--run isn't root. Verified end-to-end: joined shell gets the container's own hostname, process tree (ps shows only container processes), and root filesystem; untracked/stale pids error out cleanly without touching nsenter; Ctrl-C during the joined command doesn't disturb the original session; no leftover mounts after either exits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
26 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project state
slocker-lite (C++20, built with Meson) mounts an OCI Image Layout tar (oci-layout +
index.json + blobs/sha256/*, as produced by skopeo/podman save --format oci-archive/modern docker save) using containers-storage and fuse-overlayfs, then
(via -r/--run) runs a sandboxed command against it with bwrap. The real deployment
target is Android with a stock kernel, where podman/docker don't run (missing
namespace support) and there's no kernel overlayfs (hence fuse-overlayfs); bwrap is
invoked in "degraded mode" using only whichever --unshare-xxx namespaces the running
kernel actually supports. See README.md for the human-facing overview (build/usage/
status); this file stays the dense, file-by-file reference. Still early-stage.
Source layout (all under src/):
main.cpp— CLI entry point, dependency checks, orchestration (mount_image(),run_container(),cleanup_image(),unmount_image(),list_images_command(),inspect_image_command(),create_volume_command(),list_volumes_command(),delete_volume_command(),list_processes_command(),clean_processes_command()).list_processes_command()implements--list-processes(long-option only): callslist_sessions()(pid_file.{h,cpp}, see below) and prints one tab-alignedpid,container name,running/exitedrow per entry (same two-column tab-alignment scheme aslist_images_command()/list_volumes_command(), extended to a third column), no header row, silent success on an empty list.clean_processes_command()implements--clean-processes(also long-option only): callsclean_stale_sessions()(pid_file.{h,cpp}) and prints oneremoved stale pid file for '<name>' (pid <pid>)line per file actually removed — nothing is printed for sessions still running, and an empty result (nothing stale) is silent success, same convention as the rest of this file's list/delete commands.-e/--exec <pid>(has a short form, unlike the rest of the process-tracking flags) dispatches straight toexec_in_session()(exec_session.{h,cpp}, see below):pidlands inmode_arg(parsed as a positive integer, erroring out otherwise) the same way-r's image path does, and the trailing command (argv[optind:], required — errors out if empty) is collected the same way-r's own command is, sharing that mode's exemption from the "no leftover positional args" check.inspect_image_command()implements-i/--inspect <image.tar>: prints everyOciImageConfigfield (user/group, exposed ports, env, volumes, default command) without mounting or running the image — extend it wheneverOciImageConfiggains a new field (seeoci_image.{h,cpp}below).run_container()unconditionally callsread_oci_image_config()and reuses the result for two independent defaults: the command to run (Entrypoint ++ Cmd) when none is given on the command line, and, when--userwasn't given, the sandboxed process's user/group (config.User, split intoOciImageConfig::user/group) — an explicit--user/--groupon the command line always takes precedence.create_volume_command()implements-v/--volume <name> <directory>;list_volumes_command()implements--list-volumes(same tab-alignment scheme aslist_images_command(), reused as-is);delete_volume_command()implements both--delete-volume <name>(config entry only) and--delete-volume-full <name>(alsostd::filesystem::remove_all()s the host directory — errors out before touching the config if that fails, warns instead of failing if the directory was already gone) — seeconfig_file.{h,cpp}below for what a "volume" means here (a distinct concept fromOciImageConfig::volumes).-v/--volumeis dual-purpose: used alone it'screate_volume_command(); combined with-r/--runit instead requests a volume mount (repeatable) and is resolved byresolve_volume_mount()(seevolume_mount.{h,cpp}below) instead. Since-vmust be repeatable with-rbut each occurrence still takes two space-separated tokens,main()'s getopt loop no longer lets'v'setModeitself: it accumulates(spec, path)pairs intovolume_specs(consuming the second token manually, with a guard against swallowing the next flag if there isn't one), and only after the loop decides whether that means one standaloneMode::kVolumecall or, together with-r, threadsvolume_specsthrough torun_container().run_container()resolves each spec (erroring out,ok = false, same as a failed--userresolution —bwrapis skipped but unmount/cleanup still runs) into aResolvedVolumeMount, rejecting a duplicate or non-absolute container path first, and passes the resolved list torun_bwrap().--hostname <name>(long-option only, no short form) is likewise threaded straight throughrun_container()intorun_bwrap()/build_bwrap_args()(bwrap.{h,cpp}) — see there for how/when it actually takes effect.run_container()also derives acontainer_namefor the session-tracking pid file (seepid_file.{h,cpp}below):read_image_ref()(oci_image.{h,cpp}) applied to the single image tar being run, formatted asname:tag, falling back to the tar's own filename stem ifread_image_ref()can't determine one — passed through torun_bwrap()alongside everything else.oci_image.{h,cpp}— validates/parses the OCI Image Layout tar (libarchive + nlohmann_json) and extracts layer blobs.list_oci_images()scans a directory (non-recursively) for*.tar/*.tar.*files and, for each valid OCI archive, derives an image name/tag viaread_image_ref()from itsindex.jsonmanifest annotations (io.containerd.image.namepreferred, elseorg.opencontainers.image.ref.name), falling back to the archive's filename and"latest"respectively.read_image_ref()is public (not just an internal helper oflist_oci_images()) precisely sorun_container()(main.cpp) can reuse the exact same logic to name a single image tar's session pid file (seepid_file.{h,cpp}below) instead of duplicating it.read_oci_image_config()reads the image config blob referenced by the manifest and extractsUser(split on:intoOciImageConfig::user/group),ExposedPorts,Env,Volumes, and the effective default command (Entrypoint ++ Cmd).user/groupand the default command are consumed by-r/--run, and every field is displayed by-i/--inspect(seemain.cppabove) —ExposedPorts/Env/Volumesare otherwise still just captured for when networking/volumes are implemented.containers_storage.{h,cpp}— wraps thecontainers-storageCLI (import-layer,mount,unmount,layer --json,delete-layer), forcingfuse-overlayfsas the overlaymount_program.cleanup_layer_chain()walks a layer's parent chain (children before parents) deleting each one.bwrap.{h,cpp}—detect_bwrap_unshare_args()probes the kernel (via a forkedunshare(2)per namespace type) for which--unshare-xxxflagsbwrapcan actually use;build_bwrap_args()/run_bwrap()assemble and run the sandboxed command.build_bwrap_args()also takes astd::vector<ResolvedVolumeMount>(seevolume_mount.hbelow) and appends one writable--bind <host_directory> <container_path>per entry.wrap_for_root_namespace()isrun_bwrap()'s own nsenter-wrapping logic pulled out into a reusable, exported function — it's also whatvolume_mount.cpp's copy-into-an-empty-volume step uses to reach the image's content when running rootless (see below);run_bwrap()itself now just calls it once on the assembledbwrapargv.build_bwrap_args()deliberately drops--unshare-netfrom what's actually passed tobwrapeven when the kernel supports it — without any network setup (e.g.slirp4netns), unsharing it just leaves the sandbox with no network at all. Re-add once network isolation is implemented;detect_bwrap_unshare_args()itself still probes/reports it (e.g. via-t/--test), since that's kernel capability, not policy.build_bwrap_args()/run_bwrap()also take an optionalhostname(from--hostname, long-option only): passed through as bwrap's own--hostnameonly when--unshare-utsis actually among the flagsbwrapis being given (bwrap itself refuses--hostnamewithout it) — otherwise logs a warning and leaves the sandbox's hostname alone, since a stock Android kernel in degraded mode may not support a UTS namespace at all. Never requests--unshare-userwhen running as root: root doesn't need a fresh user namespace for privilege, and bwrap's own single-mapping uid/gid setup for one triggers the kernel's unprivileged-userns setgroups() restriction, which showed up as every other supplementary group collapsing to the overflow gid ("nobody") inid, andsuinside the sandbox failing with "can't set groups: Operation not permitted". Because of that, bwrap's own--uid/--gid(which require--unshare-user) aren't usable when running as root either —--user/--groupwork around this: when set,build_bwrap_args()/run_bwrap()bind-mount the separateslocker-lite-priv-drophelper (see below) into the sandbox at a fixed hidden path and route the real command through it as<uid>:<gid> -- <command...>. This only actually works without a user namespace (i.e. running as root) — under--unshare-user, the sandbox's uid map has only one valid entry, so the helper's ownsetuid()fails cleanly there instead of silently doing nothing.run_bwrap()fails fast (returns -1) if the helper can't be found next to this binary when--userwas requested, rather than silently running the command as root.run_bwrap()also takes acontainer_nameand tracks the running session with it: it passes a lambda asrun_process_foreground()'s newon_startcallback (seeprocess.{h,cpp}below) that callscreate_session_lock(container_name, pid)(pid_file.{h,cpp}, see below) the instant the realbwrappid is known, then callsrelease_session_lock()oncerun_process_foreground()returns (covering every exit path — normal, nonzero, or a forwarded-signal exit — since that call always blocks until the child has actually exited).priv_drop_helper.cpp→ the separateslocker-lite-priv-dropbinary (its ownexecutable()target inmeson.build, built with-static). Deliberately has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is fully statically linked: it gets bind-mounted into the container image's own filesystem, which won't haveslocker-lite's own shared library dependencies — a dynamically linked binary bind-mounted that way fails outright ("error while loading shared libraries"), which is exactly what happened before this was split out (the original approach bind-mountedslocker-lite's own — dynamically linked — binary via/proc/self/exeand reexeced it; kept only as a lesson, not as working code). Usage:slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]; doessetgroups(0,…)→setgid()→setuid()→execvp(), in that order (dropping the group needsCAP_SETGID, which is lost oncesetuid()drops root).find_priv_drop_helper()(src/bwrap.cpp) locates it next toslocker-lite's own binary (via/proc/self/exe's directory), which holds both when run straight frombuildDir/and after a realmeson install.user_spec.{h,cpp}—resolve_user_and_group()resolves a user/group spec (each a name or numeric id) against the mounted image's own/etc/passwd//etc/group(not the host's), since names likegitonly mean anything inside that image's own user database. A numeric user with no group and no matching/etc/passwdentry defaults gid to the same numeric value as the uid.ResolvedUser(bwrap.h) also carrieshome, looked up by the final resolved uid's/etc/passwdentry (field 5) regardless of whetheruserwas given as a name or a number; falls back to"/root"for uid 0 or"/"otherwise when there's no matching row.build_bwrap_args()(bwrap.cpp) sets the sandboxed process'sHOMEfrom this —"/root"only when no user override applies at all (no--user, no image-declaredconfig.User).run_container()(main.cpp) callsresolve_user_and_group()with either the explicit--user/--groupflags, or, when--userwasn't given, the image's own declaredconfig.User(OciImageConfig::user/group) — so a container defaults to running as whatever user the image itself declares, not root, unless the image declares none.process.{h,cpp}— argv-based subprocess helpers (fork/execvp, no shell):run_process()captures stdout (used forcontainers-storagecalls),run_process_foreground()inherits all of stdio (used for the interactivebwraprun). Alsofind_in_path(), a shared$PATHlookup.run_process_foreground()installs a SIGINT/SIGTERM handler around itswaitpid()that forwards the signal to the running child and keeps waiting instead of letting the default disposition killslocker-liteitself — without this, Ctrl-C (orkill) during-r'sbwraprun would skiprun_container()'s unmount/cleanup entirely, leaving the layer imported and/or mounted.run_process_foreground()also takes an optionalon_startcallback, invoked with the child's real pid right afterfork()succeeds (before the signal handlers go up and it blocks inwaitpid()) — the only point where that pid is knowable, and still accurate even whenargvitself execs into something else first (e.g.nsenterhanding off to the final command via its own in-placeexecvp()— a pid never changes acrossexec()).run_bwrap()(bwrap.cpp) is the one caller that uses it, for session pid-file tracking (seepid_file.{h,cpp}below).pid_file.{h,cpp}— tracks one running-r/--runsession (a livebwrapprocess) as a locked pid file, so an outside process (or a laterslocker-liteinvocation) can tell whether it's still running.session_pid_file_path()resolves$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>(falling back to$HOME/.local/state/...whenXDG_STATE_HOMEis unset/empty — same resolution pattern asconfig_file_path()below, for state instead of config), sanitizingcontainer_namefirst (anything outside[A-Za-z0-9._-]→_, since an image name/tag can contain/or:).create_session_lock()creates the file (O_CREAT|O_WRONLY|O_TRUNC|O_CLOEXEC, mode 0644 —O_CLOEXECmatters: this fd must never leak into the sandboxed command's own fd table), writes the pid as text, and takes an exclusive, non-blockingflock()on it — held only by that fd, so its lifetime tracksslocker-lite's own process lifetime (released automatically on any exit, including a crash), which lines up withbwrapitself being invoked with--die-with-parent. Any external tool can check liveness the same way: attempt the same exclusive non-blockingflock()on the file — success means nothing holds it anymore (stale, safe to remove),EWOULDBLOCKmeans a live process still does.release_session_lock()closes the fd (releasing the flock immediately) and removes the file. Every failure path here (can't create the directory/file, can't lock, can't remove) is aspdlog::warn, never fatal — session tracking is best-effort and must never block or fail-r/--runitself.list_sessions()implements--list-processes(main.cpp'slist_processes_command()): scans the samerun/directory and reports oneSessionInfo {pid, container_name, running}per readable pid file.pidis read from the file's own contents, not parsed from the filename (ambiguous for names that themselves contain-);container_nameis then recovered by stripping that exact-<pid>suffix back off the filename.runningreuses the same liveness check any external tool would do — a non-blocking exclusiveflock()that succeeds means the file is actually stale, sorunningis false in that case; the lock is always released again immediately either way, never left held by the check itself. A file that can't be opened or doesn't parse as a pid (e.g. removed mid-scan) is silently skipped, not reported as an error — scanning a live directory is inherently racy. Bothlist_sessions()andclean_stale_sessions()(the latter implements--clean-processes) share a privateopen_session_file()helper for the open/read-pid/recover-name step.clean_stale_sessions()doesn't just remove whatever a separatelist_sessions()call reported as not running — it re-takes the same non-blockingflock()used to test liveness and holds it across theremove()call itself, per file, so the stale check and the removal stay atomic against a new session starting in the gap between a check and a later removal. Only files it actually removes are reported back (asSessionInfos withrunning=false); still-locked (running) files are left untouched and not reported.exec_session.{h,cpp}— implements-e/--exec <pid>: joins an already-running-r/--runsession's namespaces viansenterand runs a command inside it in the foreground.exec_in_session()first confirmspidis a tracked, running session vialist_sessions()(pid_file.h) — same liveness check--list-processes/--clean-processesalready use, no new logic needed there. Key discovery, confirmed by direct testing, not assumed:pid(the onerun_process_foreground()captured and pid-file-tracked when-rlaunchedbwrap) is bwrap's own outer process — it sets up the mount and user namespaces itself, thenclone()s the actual sandboxed command into fresh pid/uts/ipc/cgroup namespaces, andclone()'s namespace-creation flags only ever affect the newly created child, never the caller. So the outer process itself never actually enters those namespaces — comparing/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}against this process's own showed them identical, while onlymnt/userdiffered.resolve_namespace_pid()reads/proc/<pid>/task/<pid>/children(the direct-children listprocfsexposes) to find that real inner process and joins its namespaces instead — falls back topiditself (best-effort, not fatal) if that file can't be read. For each of{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid, cgroup→--cgroup, user→--user}(netdeliberately excluded — this project never isolates networking, seebwrap.cppbelow),readlink()s both/proc/<ns_pid>/ns/<type>and/proc/self/ns/<type>and only passes nsenter's corresponding--type=/proc/<ns_pid>/ns/<type>flag when they differ — an identical-namespace re-entry attempt can fail outright (setns()'s ownEINVALrestriction on re-entering a namespace you're already in), so skipping is deliberate, not just an optimization.mntis the one type where a read failure (permission denied, or the process vanished) is treated as fatal, since without it "joining the container" is meaningless; every other type just degrades to a skip. Always appends--preserve-credentials: without it,nsenter --useralso tries tosetuid()/setgid()/setgroups()to the target's identity within the new user namespace, which fails outright (setgroups failed: Operation not permitted) against thesetgroups-denied unprivileged user namespace bwrap creates whenever-r/--runisn't root — confirmed by hitting this exact failure during manual testing before adding the flag. Runs the finalnsenter ... -- <command>via the existingrun_process_foreground()(process.h) — same inherited stdio and SIGINT/SIGTERM forwarding as every other foreground external command, no new process-running logic needed.config_file.{h,cpp}—load_config_file()reads and parses (via libyaml's document API,<yaml.h>) theglobalandvolumessections of the local YAML config file located byconfig_file_path()($XDG_CONFIG_HOME/slocker-lite/config.yaml, falling back to$HOME/.config/slocker-lite/config.yaml).global.log-levelis the only supportedglobalkey — other long options are one-shot flags, not settings, so they don't belong in a persistent config file. A missing file returns a default-constructed (empty)AppConfig, not an error; unknown sections/keys (and malformed individual volume entries) are ignored for forward-compatibility; malformed YAML syntax is a hard error.main()appliesconfig->log_level(via the existingapply_log_level()) right afterspdlog::cfg::load_env_levels()and before parsing CLI options, so an explicit--log-levelon the command line always overwrites it afterward — same precedence pattern already used forSPDLOG_LEVEL.write_config_file()writes the whole file back out (via libyaml's document-building/emitter API, symmetric to the read side) — used by-v/--volume(create_volume_command(),main.cpp) to persist a newVolumeEntry {name, directory}into thevolumessection, preservingglobaluntouched.VolumeEntry/thevolumessection is a distinct concept fromOciImageConfig::volumes: this is a user-definedname -> host directorymapping created via-v/--volume, not an image's own declared mount points (still unconsumed, seeoci_image.{h,cpp}above).AppConfig/config.volumesis looked up by name inresolve_volume_mount()(volume_mount.{h,cpp}, see below), which is how-r/--run's own-vusage finds a named volume's host directory.volume_mount.{h,cpp}—is_valid_volume_name()(no/, checked by bothcreate_volume_command()and to tell a-vspec's name/path apart) andresolve_volume_mount(), called once per-voccurrence fromrun_container()when running with-r. A spec with no/is looked up inconfig.volumesby name (error if unknown); one with/is treated as a host directory path andcreate_directories()'d if missing. If the resulting host directory is empty and the image already has non-empty content at the given container path, that content is copied in first. Both the existence check and the copy run as a singlesh -c '[ -d ... ] && cp -a ...'invocation wrapped throughwrap_for_root_namespace()(bwrap.h) — not a plainstd::filesystemcheck — because a rootlesscontainers-storage mount's content isn't visible to this process at all withoutnsenter, the same constraintrun_bwrap()itself works around (see the root-vs-rootless paragraph below).cp -a --preserve=mode,ownership,timestamps,links[,xattr]does the copy; whether,xattris included is decided by a directsetxattr()/removexattr()probe on the host directory (no new library dependency — Linux POSIX ACLs are themselves stored as xattrs, so this one probe stands in for both, logging a singlespdlog::warnif unsupported). A nonzerocpexit is only ever a warning, never fatal — often just an ownership-preservation shortfall when not running as root.
Errors are logged via spdlog::error; every external command is also traced at debug
level in run_process()/run_process_foreground() (src/process.cpp) — visible via
SPDLOG_LEVEL=debug, since spdlog's default level is info — and a failed external
command additionally logs a spdlog::warn, which is visible by default (no env var
needed). The final "mounted image at: ..." success line is direct stdout program
output, not a log.
Because containers-storage mount runs rootless, it reexecs itself into a private
user+mount namespace to gain the privilege it needs for the overlay mount — which
leaves the result invisible to a plain shell or child process outside that namespace.
Confirmed containers-storage unshare does not rejoin an already-running mount's
namespace; only nsenter targeting the live fuse-overlayfs daemon's PID does.
-r/--run handles this automatically by locating that PID and running bwrap via
nsenter into its namespaces (wrap_for_root_namespace(), src/bwrap.h) — reused
as-is by volume_mount.cpp's copy-into-an-empty-volume step, since that also needs
to read image content that's otherwise invisible outside the same namespace.
Running as root sidesteps all of this: no privilege
reexec is needed, so the mount is already directly visible in the current namespace,
and nsenter --user=... into it then fails ("reassociate to namespace 'ns/user'
failed: Invalid argument") since the caller is already in that same user namespace.
-r/--run detects geteuid() == 0 and skips nsenter automatically in that case;
-n/--no-nsenter forces it off manually for any other situation where the mount turns
out to already be directly visible.
Build & test commands
Build directory is buildDir/ (already configured).
- Configure (only needed if
buildDir/is missing or deleted):meson setup buildDir - Build:
meson compile -C buildDir(orninja -C buildDir) — also buildsbuildDir/slocker-lite-priv-drop, the statically-linked helper-r --userneeds (seepriv_drop_helper.cppin "Project state") - Run the executable:
./buildDir/slocker-lite -m <image.tar>(see--helpfor the full flag list:-m/--mount,-r/--run,-u/--umount,-c/--cleanup,-l/--list-images,-i/--inspect,-e/--exec,-n/--no-nsenter,--user,--group,--hostname,-v/--volume,--list-volumes,--delete-volume,--delete-volume-full,--list-processes,--clean-processes,-t/--test,--log-level,-h/--help,-V/--version) - Run tests:
meson test -C buildDir
Code style
- Null-pointer checks: prefer
if (!ptr)/if (ptr)overif (ptr == nullptr)/if (ptr != nullptr).
Licensing
- Every
.c/.cpp/.hfile undersrc/must start with the GPLv2-or-later copyright header (see any existing file undersrc/for the exact text). - After adding a new source file under
src/, run./add-license.shfrom the repo root to prepend the header (it readscopyright-headerand inserts it viased, skipping files that already have it, so it's safe to re-run at any time).
Build configuration notes
meson.buildsetswarning_level=3andcpp_std=c++20— keep new code warning-clean under-Wall -Wextra -Wpedantic-equivalent settings.- The single Meson
test()target runsslocker-liteagainst a fixture OCI image tar generated at build time bytests/gen_fixture.py(acustom_target) and checks its exit code (no test framework is wired in yet).