Most teams expect AppArmor Px transitions, Cx, ix, and px to be interchangeable execute-mode letters, but the hidden risk is that each mode hits a different kernel path. Px execs into a globally loaded profile and scrubs loader-sensitive environment variables; ix inherits the parent profile; Cx targets a local child profile inside the parent; lowercase px keeps the global transition but disables the scrub. That means a wrong letter can silently preserve too much authority, fail with a missing profile error, or keep unsafe environment state across an exec boundary.
More on Apparmor Profile Transitions Px.
A Px transition exec’s a binary into a different AppArmor profile and scrubs the environment of variables like LD_PRELOAD. ix keeps the binary under the parent’s profile with the environment intact. Cx looks like Px but only resolves to a local child profile defined inside the current one.
The lowercase variants — px, cx, ux — skip environment scrubbing. Everything else in apparmor.d(5)’s execute-mode list, including the fallback combos like Pix and CUx, is a recombination of those three axes.
- Px = transition to a global profile, environment scrubbed; px = same target, environment preserved (unsafe).
- Cx targets must be a LOCAL child profile declared inside the current profile;
Cx -> globalnamefails atapparmor_parserload time, not at exec time. - ix performs no profile transition — the executed binary keeps running under the parent profile, so any new rules you add to the parent immediately apply to it.
- A missing Px target produces an audit record indicating a denied exec operation; the exec(2) call returns EACCES to the caller.
- Hats (entered via
aa_change_hat(2)) are not exec-time targets — you cannot use a hat name as a Px target, and a hat cannot itself contain child profiles.

AppArmor’s exec mediation is two operations, not six letters
When a confined process calls execve(2), the kernel’s AppArmor LSM hooks make one binary decision before anything else: does the new image keep running under the calling profile, or does the kernel attach a different profile to the new task_struct? Those are two physically different code paths in the kernel, with different audit signatures and different security guarantees. Calling them both “execute modes” obscures the split.
Inheritance keeps the parent profile attached to the new image. The mediation policy doesn’t change, the environment passes through unchanged, and from /proc/self/attr/current‘s perspective nothing has shifted.
A related write-up: system call internals.
Transition swaps the attached profile for a different one. On the way through, the kernel scrubs LD_PRELOAD, LD_LIBRARY_PATH, and other sensitive environment variables.
The capital/lowercase split (Px vs px, Cx vs cx, Ux vs ux) is independent of inherit-vs-transition: it toggles whether AppArmor asks the kernel for that scrub. The apparmor.d(5) man page documents both halves of this — the table of available transitions and the separate treatment of the lowercase modes as the variants that leave the environment intact. The two halves aren’t a single concept.
The 2×2×2 decomposition that derives every mode letter
Three independent yes/no choices generate the entire mode-letter zoo:
- Inherit (i) or transition (p / c)? Inherit means the child runs under the parent profile. Transition means the child runs under a new profile.
- If transition, global (p) or local (c)? A global target is a profile loaded by its full name into the kernel’s profile namespace. A local target is a child profile syntactically nested inside the current profile and only resolvable from within it.
- Safe (uppercase) or unsafe (lowercase)? Safe sends the exec through the kernel’s environment-scrubbing path. Unsafe doesn’t.
Cross-multiply those and you get the canonical six: Px, px, Cx, cx, ix (only one form — there’s nothing to scrub if no transition happens), and ux/Ux for the “transition to unconfined” case. The AppArmor Core Policy Reference on the upstream wiki spells out the same axes. The SUSE and openSUSE docs present them as a flat table, which is exactly why the relationship gets lost.
The fallback variants — Pix, pix, Cix, cix, PUx, pux, CUx, cux — add a fourth axis: “if the transition target doesn’t resolve, fall back to inherit (i) or unconfined (u) rather than failing the exec.”
Every letter you encounter in a profile is some combination of those four dials. There is no “Px is its own thing” carve-out.
Collapsing those four axes into a single reference matrix makes it easier to read a profile and predict what the kernel will do:
| Mode | Kernel operation | Target namespace | Env scrub | Behavior on miss | How to verify it fired |
|---|---|---|---|---|---|
Px |
Domain transition | Global | Yes | EACCES + audit record | /proc/self/attr/current shows new profile; LD_PRELOAD stripped |
px |
Domain transition | Global | No | EACCES + audit line | New profile in attr; LD_PRELOAD survives |
Cx |
Domain transition | Local (declared inside parent) | Yes | Parser rejects at load — profile never enters kernel | attr shows parent//child form; env scrubbed |
cx |
Domain transition | Local | No | Parser load failure | parent//child in attr; env preserved |
ix |
Inherit (no transition) | n/a | No (no transition to gate on) | No transition-target lookup; normal exec permission checks (DAC, file rules, capabilities) still apply | attr unchanged from parent; env unchanged |
Ux |
Transition to unconfined | n/a | Yes | n/a | attr reads unconfined; env scrubbed |
ux |
Transition to unconfined | n/a | No | n/a | attr unconfined; env preserved (dangerous) |
Pix |
Px → fallback to ix on miss | Global, then parent’s profile | Yes on hit; n/a on inherit fallback | Silently inherits parent profile | attr shows target on hit, parent name on fallback |
Cix |
Cx → fallback to ix | Local, then parent’s profile | Yes on hit | Parser load failure if local target missing | attr shows child or parent depending on resolution |
PUx |
Px → fallback to Ux on miss | Global, then unconfined | Yes (always) | Drops to unconfined — accepts security cost | attr shows target or unconfined |
CUx |
Cx → fallback to Ux on miss | Local, then unconfined | Yes | Parser load failure for missing local; otherwise drops unconfined | attr shows child or unconfined |
Two rows in that table do most of the day-to-day work for service profiles — Px and Pix. The rest exist to handle corner cases: workers spawned by a daemon, intentional unconfinement, and tracing wrappers.
Read top to bottom, every mode is determined by which combination of the four axes it expresses.

Captured output from running it locally.
What the kernel actually does on a Px exec
An exec under a Px rule walks roughly this sequence:
- The LSM exec hook fires, AppArmor looks up the calling profile’s exec rule for the target path, and matches mode
Px. - It resolves the target profile name — by default the basename of the executable, unless
Px -> explicit_targetis given. - It performs a profile lookup against the global profile namespace.
- On hit, it marks the exec as a domain transition.
- The kernel scrubs
LD_PRELOAD,LD_LIBRARY_PATH, and the rest of the dynamic-loader-sensitive variables. - The new task’s security context points at the new profile.
On miss — the profile name doesn’t resolve — AppArmor short-circuits the exec. The syscall returns EACCES to userspace and an audit record gets emitted indicating the denied exec.
There is a longer treatment in how the kernel handles exec.
Why “Cx -> globalname” and “Px -> missing” fail
These look like the same class of error and aren’t. A Cx -> specialprofile rule where specialprofile isn’t declared as a child profile inside the current profile gets rejected at policy load time, not exec time.
apparmor_parser resolves Cx against the current profile’s local namespace only. If there’s no profile specialprofile { ... } block syntactically nested inside the parent, the load fails with a parser error referencing the missing local hat/child, and the profile never reaches the kernel.
A Px -> missing rule, by contrast, is syntactically legal. The parser doesn’t verify that the global target exists — global profiles can be loaded in any order, so a forward reference is normal.
The lookup happens at exec time, against whatever’s in the kernel’s namespace at that moment. It produces a transition denial in the audit log.
Two different failure stages, two different remedies:
Cxmiss → fix the profile’s syntax (the local target isn’t declared inside the parent).Pxmiss → load the target profile, or switch toPixif you genuinely want inherit-on-miss.
The QuickProfileLanguage wiki page hints at this asymmetry by listing local profiles in a different syntactic position from global ones, but doesn’t connect that to the error you actually see.
The connection is mechanical: local resolution is a compile-time lookup; global resolution is a runtime lookup. The mode letter you choose tells the parser which lookup to perform.

Purpose-built diagram for this article — Inside AppArmor: how profile inheritance and Px transitions actually work.
Hats are a third thing entirely
Hats look like child profiles but they aren’t reachable through exec. A hat is entered with aa_change_hat(2) — a userspace library call from libapparmor that flips the process’s current attr without performing an exec.
The kernel treats hats as a sub-namespace of the parent profile, not as exec-time transition targets. Two consequences fall out of that:
- You cannot use a hat name as a Px target. The parser will accept
Px -> foosyntactically, but iffoois only declared as a hat (using the^foosyntax) and not as a top-level profile, the runtime lookup fails the same way it would for any other missing global. The hat is in the parent’s sub-namespace, not in the global one. - Hats cannot themselves contain nested child profiles.
apparmor_parserrejects this at load time. Hats are designed for stack-like permission narrowing within one process (think the Apache mod_apparmor model where each PHP request lives in a hat), not for spawning new confinement domains.
Letting a hat declare children would create a profile graph the kernel can’t represent in its current attr layout.
The practical confusion this causes: someone writes a profile for php-fpm, declares ^worker as a hat, then writes /usr/bin/php Px -> worker, and watches every request log a transition denial.
The hat exists in the parent’s sub-namespace, but Px is looking in the global namespace, and never finds it.
The fix is to either change ^worker to a child profile (profile worker { ... } declared without the caret) and use Cx -> worker, or to keep the hat and switch to aa_change_hat() from inside the PHP handler.

Live data: PyPI download counts for inside.
If you’re working from a profile that was auto-generated by aa-genprof, the mode letters it picked are the ones the tool inferred from your test run — not always the ones you want. The interactive utilities shipped in the apparmor-utils package (aa-genprof, aa-logprof, aa-complain) make a best-effort guess from the audit records they replay, so it pays to walk the 2×2×2 decomposition over each transition rule before accepting its suggestions.
Verifying a transition actually fired
Three commands tell you whether the transition you wrote in the profile is the transition the kernel performed. None of them require special tooling beyond what ships with the apparmor package on Debian, Ubuntu, Fedora, and openSUSE.
aa-statusshows the loaded profile inventory and the processes currently confined by each one. If your Px target appears under “profiles are in enforce mode” but no processes show under it after the parent execs the child, the transition didn’t fire — most likely a parser-level mode mismatch (you wroteixwhen you meantPx).cat /proc/self/attr/currentinside the child — e.g. by exec’ing a wrapper that prints it beforeexec‘ing the real binary — shows the exact profile name and mode the kernel attached. The output looks likechild_profile (enforce)for a successful transition,parent_profile (enforce)if you actually got ix instead, orunconfinedif aPuxrule fell back.- Diffing
envoutput between a Px-entered process and a px-entered one shows the environment scrub:LD_PRELOADset in the parent shell survives into px but not Px. That diff is the single most direct confirmation that the safe/unsafe axis worked.
A short Python helper makes this loop reproducible across rule changes:
For more on this, see production shell habits.
import os, subprocess, sys
def show_confinement(label):
with open("/proc/self/attr/current") as f:
attr = f.read().strip()
ld = os.environ.get("LD_PRELOAD", "<unset>")
print(f"{label}: profile={attr!r} LD_PRELOAD={ld!r}")
if __name__ == "__main__":
show_confinement(sys.argv[1] if len(sys.argv) > 1 else "child")
Drop that as a small wrapper on PATH, exec it from the parent under each candidate mode (Px, px, Cx, ix), and the printed line tells you exactly what landed. Pair it with journalctl -k -g apparmor to catch the corresponding audit records.

A four-question rubric for picking the right mode letter
Walk these questions in order. The answers map to exactly one mode letter (plus an optional fallback suffix).
- Does the child need its own confinement policy, or should it run under the parent’s? Under the parent’s →
ix. Stop. Its own → continue. - Is the target profile a global profile loaded separately, or a child profile declared inside this profile? Global →
Pxfamily. Local →Cxfamily. - Does the child run with privileges (or handle untrusted data) such that
LD_PRELOADfrom the caller must not survive? Yes (almost always) → capital letter. No (you have a specific reason to preserve environment, e.g. a tracing wrapper) → lowercase. - If the target profile is missing at exec time, what should happen? Fail with EACCES → bare
Px/Cx. Fall back to inheriting the parent →Pix/Cix. Fall back to unconfined →PUx/CUx(and accept the security cost).
When to pick each mode
Phrased as if/then choices, the rubric collapses into six common cases:
deployment command toolkit goes into the specifics of this.
- Pick
Pxif the child binary has its own globally loaded profile, you control the deploy order so the target is already in the kernel before the parent runs, and you want the exec to fail loudly if it isn’t. This is the safe default for a service binary thatexec‘s a helper with its own loaded profile. - Choose
Pixif the deploy order isn’t guaranteed (boot-time races, lazy profile loading) and the parent’s policy is acceptable as a fallback. You accept that a missing target silently runs under the parent rather than EACCES’ing. - Pick
Cxif the child profile only makes sense in the context of this one parent — a daemon spawning narrowed worker processes that nothing else on the system will ever exec. The local-namespace constraint is a feature: nothing outside the parent can accidentally enter that profile. - Pick
ixif the child is logically part of the same trust domain — wrappers, helper binaries, loader processes, shell pipelines where the parent’s policy already mediates what the child can touch. - Choose
PUx/CUxonly if you genuinely want the missing-target case to drop to unconfined (typical: a setup or admin helper that hands off to a user-controlled binary), and you’ve documented the security trade-off in the profile. - Avoid lowercase modes (
px,cx,ux) unless you have a specific reason the child must inheritLD_PRELOAD— e.g. a tracing harness, a profiler that injects via the loader, or a debug build that requiresLD_LIBRARY_PATH. The default answer to “should I use lowercase?” is “no, use the uppercase and write a wrapper if you need environment plumbing.”
If you find yourself reaching for ux or Ux outside of those documented cases, stop and check whether you meant Pix instead. “Transition out of confinement entirely” is rarely what you actually want, while “transition to a real profile, fall back to the parent if it’s missing” usually is.
The SUSE SLES 15 SP6 AppArmor profile-syntax chapter covers the same execute modes in its distro-side reference. Framing the lowercase-vs-uppercase choice as the last branch of the rubric makes the trade-off load-bearing rather than ornamental.

Radar of AppArmor Profile Mechanics.
What this means for the next profile you write
The mode letter zoo is not a memorization problem. It’s a three-axis decomposition (inherit-vs-transition, global-vs-local, safe-vs-unsafe) plus a fallback suffix, and every error message the parser or kernel emits about transitions points at exactly which axis you got wrong.
Write the rule from the rubric, verify with /proc/self/attr/current and an env diff, and the next time you see a transition denial in the audit log you’ll know whether to load a missing profile, fix a local-vs-global confusion, or stop trying to Px into a hat.
There is a longer treatment in kernel tuning patterns.
You might also find filesystem path resolution useful.
Continue with distro-level security primitives.
Further reading
- apparmor.d(5) on manpages.ubuntu.com — the canonical execute-mode reference, including the full list of fallback variants.
- AppArmor Core Policy Reference (upstream wiki) — the project’s own walk through profile syntax and the safe/unsafe axis.
- QuickProfileLanguage wiki page — short syntax tour that distinguishes local child profiles from global ones in its grammar position.
- SUSE SLES 15 SP6 — Profile components and syntax — vendor reference for the same execute modes, useful for distro-side wording.
- apparmor_parser(8) on manpages.ubuntu.com — describes the load-time lookup behavior that drives the Cx-vs-Px error-stage difference.




