Config-file path confinement¶
A deploy writes config files to the target as root: the backend's generated units and YAML (std.* output) plus any stack-declared configFiles. Both carry a path string the stack author controls, and both flow to the host filesystem. Left unchecked, a configFiles[].path of /root/.ssh/authorized_keys, /etc/sudoers.d/00-pwn, or /etc/ld.so.preload would be a root-equivalent write. Path confinement (B3b / R4) is the gate that closes that hole.
This is the path-layer sibling of the validate-twice injection guard: same module, same defense-in-depth shape, applied to where a file lands rather than what a directive contains.
Where a configFiles[].path may target¶
A config path must be a plain absolute path whose normalized form sits under one of four allowlisted roots — and not on a curated privilege-escalation denylist:
/etc— everystd.*generator targets/etc(/etc/systemd/system/,/etc/nginx/conf.d/,/etc/prometheus/,/etc/litestream.yml, …), and operators legitimately add service configs there (/etc/openbao/openbao.hcl,/etc/caddy/Caddyfile)./srv— static web roots (/srv/www/index.html)./var— app data and config./opt— vendored applications.
The roots are exhaustive: anything outside them is refused. That already excludes the dangerous trees — /root, /usr, /bin, /sbin, /boot, /lib — with no per-path rule. A path equal to a root (/etc) is not a writable file target either; the match requires a path strictly beneath a root.
The deployments root and the store root are deliberately absent from CONFIG_ROOTS: they are written by the manifest and store-push, never through a config-file path.
The privilege-escalation denylist¶
/etc has to stay broad — operators put real service configs there — so a flat "/etc is allowed" rule would admit /etc/sudoers.d/00-pwn. A second, narrower check refuses a curated set of privesc paths under an allowed root even though they pass the root test:
CONFIG_DENY = (
"/etc/sudoers", "/etc/sudoers.d", # grant sudo
"/etc/ssh", # sshd_config, sshd_config.d/, host keys
"/etc/pam.d", "/etc/security", # the PAM stack / limits
"/etc/profile", "/etc/profile.d", # login-shell env for every user
"/etc/environment", # session env (PATH, …) for every login
"/etc/crontab", "/etc/cron.d", # cron — each path its own component
"/etc/cron.hourly", "/etc/cron.daily",
"/etc/cron.weekly", "/etc/cron.monthly",
"/etc/cron.allow", "/etc/cron.deny",
"/etc/passwd", "/etc/shadow", # account databases
"/etc/group", "/etc/gshadow",
"/etc/ld.so.preload", "/etc/ld.so.conf", # the dynamic loader — direct RCE
"/etc/ld.so.conf.d",
)
A config file granting sudo, an sshd drop-in, a login-env shim, a cron job, or a preloaded shared object is a host takeover, not a service config — so it is refused regardless of being under /etc.
Why a denylist on top of an allowlist (Option A)¶
This project's discipline is allowlist-not-denylist, and the denylist looks like a departure. It is not the sole gate — it is defense-in-depth narrowing the one root that must stay broad. The alternative considered (ADR-026 Option C) was a strict allowlist of only the prefixes std.* generators own plus a short operator list; that breaks the real corpus (/etc/openbao/, /etc/caddy/, /etc/hello.conf) — every new service config would need a core edit. Option A is the only choice that refuses every spec privesc example and keeps the corpus working with zero edits. The cost is a short, reviewable list that grows as new well-known privesc paths surface.
Denylist matching is component-aware¶
Matching is on the normalized path and respects path-component boundaries, not bare str.startswith. A denylist entry matches only when the path is the entry or sits strictly beneath it (entry + "/"):
/etc/sudoers.drefuses/etc/sudoers.dand/etc/sudoers.d/00-pwn./etc/groupdoes not refuse the legitimate lookalikes/etc/group-(a backup),/etc/grouptest, or/etc/cronie.conf.
Each dangerous path is listed as its own component for the same reason: that is why /etc/sudoers and /etc/sudoers.d both appear, and why the full crontab/cron.d/cron.{hourly,daily,weekly,monthly}/cron.allow/cron.deny set is enumerated rather than collapsed to a prefix.
Matching is case-sensitive on purpose: the deploy target is Linux (a case-sensitive filesystem), so /etc/SUDOERS is a different, harmless file. Folding case would wrongly refuse legitimate mixed-case paths.
Normalized matching¶
Both the root test and the denylist run against os.path.normpath(path), so alias spellings cannot slip past:
./and//interior segments are collapsed —/etc/nginx/conf.d/./app.confnormalizes to/etc/nginx/conf.d/app.conf, so it cannot alias-override a generated vhost, and/etc/sudoers.d//00-pwncannot dodge the denylist.- A POSIX-preserved leading
//(a network-path prefixnormpathkeeps) is collapsed to a single/before matching. ..is rejected upstream byvalid_abs_path— a..component never reaches the normalize step, so traversal like/etc/nginx/conf.d/../../sudoers.d/xis refused as "not a plain absolute path".
A normalized form (os.path.normpath) also drives the collision check: no two config files may normalize to the same target (interior ./ and // aliases are caught). Because generated and stack-declared files share one list, this also reserves std.* output paths — a configFiles entry that normalizes onto a generated unit or vhost is a hard error, not a silent last-write-wins.
The realpath re-check (LocalTransport)¶
The string check above sees only the declared path. It cannot see a symlink that was pre-planted in the target's parent chain. So on a local target, LocalTransport.write(..., confine_config=True) re-checks the resolved path (os.path.realpath, every symlink followed) before writing:
- the resolved target must still be under an allowed root (catches
/etc/x→/redirecting the write out of bounds); - the resolved target must still not be on/under a denied path (catches
/etc/alias→/etc/sudoers.d, a string-benign path that resolves into the denylist).
The roots and denylist prefixes are themselves realpath'd for the comparison, so a symlinked sandbox root (macOS /tmp → /private/tmp) compares consistently.
Applies to both, fail-closed, exit 1¶
Confinement runs at the kernel write boundary — cli._check_config_paths, the single chokepoint every config write passes — over the combined list of generator output and raw configFiles. No recipe and no stack can opt out. It is fail-closed: the check runs before any byte is written, and a violation raises ConfigPathError, which the deploy command catches and turns into a located error: on stderr with exit code 1.
$ punix service deploy PwnStack --file stack.pcl
error: refusing to write config file '/etc/sudoers.d/00-pwn': a privilege-escalation path (sudoers/ssh/pam/cron/passwd/ld.so/…)
$ echo $?
1
Accepted vs refused¶
| Path | Verdict | Why |
|---|---|---|
/etc/openbao/openbao.hcl |
accept | under /etc, not denied |
/srv/www/index.html |
accept | under /srv (static web root) |
/etc/nginx/conf.d/app.conf |
accept | unless it collides with a generated vhost |
/etc/sudoers.d/00-pwn |
refuse | denylist (sudo grant) |
/root/.ssh/authorized_keys |
refuse | outside the allowed roots |
/etc/ld.so.preload |
refuse | denylist (loader RCE) |
/etc/nginx/conf.d/../../sudoers.d/x |
refuse | .. component (valid_abs_path) |
/etc/nginx/conf.d/./app.conf aliasing a std.* vhost |
refuse | normalized collision with the generated file |
Honest residual / limitations¶
This is defense-in-depth, not a hermetic boundary. Three limits are deliberate and documented:
- SSH symlink TOCTOU is the v1 residual.
SshTransport.writeacceptsconfine_configbut does not enforce a remote realpath re-check: even a remote check still leaves a window between the check and themvwhere a pre-planted symlink can redirect the write. Closing it needsO_NOFOLLOW-relative writes, which is deferred (ADR-026 / spec R4: "not required for v1"). The string-level confinement at the chokepoint still runs for SSH deploys; only the realpath re-check is local-only. - The denylist is inherently incomplete. It enumerates known privesc paths and must be extended as new ones surface (polkit, systemd generator dirs, …). It is defense-in-depth narrowing the broad
/etcroot, not the sole gate — the root allowlist is what bounds the blast radius. - Case-sensitive by design. Matching does not fold case; this is correct for the Linux target and would be wrong for a case-insensitive filesystem.
A stack author who can declare configFiles is already trusted to deploy as root on that host. Confinement raises the bar against accidents and contains the eventual out-of-tree-recipe case; it is not a privilege boundary the in-tree author was ever on the wrong side of.
Where in the code¶
src/punix/deploy/validate.py—CONFIG_ROOTS,CONFIG_DENY,confined_config_path(the reason-or-Nonechokepoint),config_path_under(the shared component-aware matcher),ConfigPathError.src/punix/cli.py—_check_config_paths(the chokepoint: confine + de-collide onnormpath).src/punix/deploy/transport.py—LocalTransport._confine_realpath(the local realpath re-check);SshTransport.write(accepts, defers — the TOCTOU residual).
Related¶
- Stacks:
configFiles— the field this gate guards. - Web & config generators — the validate-twice injection guard (the directive-layer sibling).
- Secrets at deploy — why a 0600 secret-bearing config file's bytes never enter the manifest.
- ADR-026 — the policy decision (Option A: allowlisted roots + privesc denylist).