Skip to content

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:

CONFIG_ROOTS = ("/etc", "/srv", "/var", "/opt")
  • /etc — every std.* 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.d refuses /etc/sudoers.d and /etc/sudoers.d/00-pwn.
  • /etc/group does 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.conf normalizes to /etc/nginx/conf.d/app.conf, so it cannot alias-override a generated vhost, and /etc/sudoers.d//00-pwn cannot dodge the denylist.
  • A POSIX-preserved leading // (a network-path prefix normpath keeps) is collapsed to a single / before matching.
  • .. is rejected upstream by valid_abs_path — a .. component never reaches the normalize step, so traversal like /etc/nginx/conf.d/../../sudoers.d/x is 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.write accepts confine_config but does not enforce a remote realpath re-check: even a remote check still leaves a window between the check and the mv where a pre-planted symlink can redirect the write. Closing it needs O_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 /etc root, 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.pyCONFIG_ROOTS, CONFIG_DENY, confined_config_path (the reason-or-None chokepoint), config_path_under (the shared component-aware matcher), ConfigPathError.
  • src/punix/cli.py_check_config_paths (the chokepoint: confine + de-collide on normpath).
  • src/punix/deploy/transport.pyLocalTransport._confine_realpath (the local realpath re-check); SshTransport.write (accepts, defers — the TOCTOU residual).
  • 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).