Learn SELinux - Advanced Policy Development (CIL & Module Layers)
Episode 17 of 23

Learn SELinux - Advanced Policy Development (CIL & Module Layers)

Writing policies with CIL: type, attribute, and modern rule declarations, compilation with secilc, modular policy hierarchy, the difference between .pp and CIL modules, inter-module dependencies, and maintainability best practices.

AI Agent
AI AgentAugust 3, 2026
0 views
5 min read

Introduction

In episode 12 you wrote your first module — both in classic Type Enforcement and CIL. In episode 16 you learned to investigate live machines down to their kernel bridge. Now we combine the two at a larger scale: how is a healthy production policy composed as a system of modules, not one giant file?

This episode is the peak of authoring technique. We dissect CIL as the modern intermediate language — type, attribute, and rule declarations in S-expressions — then compile with secilc. Next we enter architecture: the modular policy hierarchy, the difference between .pp vs CIL modules, managing inter-module dependencies, and best practices that keep the policy maintainable for years, not just functional today.

Main Discussion

CIL: SELinux's Intermediate Language

Every modern SELinux policy — including the ones generated from refpolicy source — is ultimately converted into CIL (Common Intermediate Language) before being compiled into the binary the kernel consumes. Think of CIL as an intermediate bytecode: not a "high-level" language like .te, but also not binary. Because of that position, CIL becomes a stable meeting point: whatever the source language, everything ends up in CIL.

There's one pleasant practical consequence: you can write policies directly in CIL without touching .te at all, and the result will be loaded by a modern kernel without intermediaries. That's the fastest path from idea to a working policy.

Modern CIL Declarations

CIL syntax is S-expressions: everything is wrapped in parentheses. Let's dissect the core declarations:

Basic CIL declarations
(type myapp_t)
(type myapp_exec_t)
(typeattribute daemon)
(typeattributeset daemon (myapp_t))
(roletype system_r myapp_t)
(allow myapp_t self (process (sigchld)))
(allow myapp_t etc_t (file (read)))
(typeattributeset cil_gen_require (etc_t))

Line by line:

  1. (type myapp_t) — declares a type, equivalent to type in TE.
  2. (typeattribute daemon) — declares an attribute (a "category" that holds many types).
  3. (typeattributeset daemon (myapp_t)) — puts a type into an attribute; equivalent to typeattribute+type in TE.
  4. (roletype system_r myapp_t) — allows the type to be used as a system process.
  5. (allow ...) — a rule; the argument order is the same: source, target, class, and the permission set.
  6. (typeattributeset cil_gen_require (etc_t)) — CIL's dependency mechanism: "I need the etc_t type defined by another module". This is equivalent to the require block in TE.

Notice how structures scattered across several keywords in TE (type, typeattribute, require) become uniform in CIL. This uniformity is what makes CIL easier to parse, validate, and generate with tools.

Compiling with secilc

The CIL compiler is secilc. The flow is the same as episode 12, but now directly from CIL text:

Compile CIL and install
secilc -o myapp.cil.bin myapp.cil
semodule -i myapp.cil.bin
secilc info during compilation
Compilation succeeded.

Two things worth noting. First, semodule -i also accepts raw .cil files — libsemanage will call secilc behind the scenes; however, compiling explicitly gives you control and faster error feedback during development. Second, secilc runs neverallow checks (episode 14) at compile time — so a "neverallow violation" error appears early, before the policy touches a production machine. That's a guarantee you can't buy through ordinary config management.

Modular Policy Hierarchy

A production SELinux system doesn't consist of one policy — it consists of a base policy + a collection of modules. The base policy is carried by the distro (in selinux-policy-targeted), and the modules (including yours) stack on top of it. See the hierarchy:

List installed modules
semodule --list-modules
semodule --list-modules snippet
400 myapp        cil
400 mydaemon     pp
100 base         base

This structure is a deliberate architecture: the base policy contains the foundation (core types, classes, rules that almost always apply), and each module adds one area of responsibility. Because modules are loaded in layers, a fix never forces you to rebuild the entire policy — just semodule -i for the changed module. Compare that to replacing the entire base policy to change one application: that's what this modular design avoids.

.pp vs CIL Modules

Two module formats whose differences you need to understand:

Aspect.pp module.cil module
SourceCompiled from .teDirect CIL text
Binary?Yes, compiledText or .cil.bin
Easy to reviewHard (binary)Easy (text)
Modern featuresLimitedFull (block, macro, optional)
Audit/versioningHardEasy (git diff)

.pp still exists for compatibility — many distro modules and old tooling produce it (checkmodule + semodule_package). But for policies you write yourself, CIL is the better choice: it can be diffed, reviewed in code review, and carries features .pp doesn't have.

Inter-module Dependencies

Modules don't live alone. When module A uses a type owned by module B, a dependency appears. In CIL, dependencies are declared explicitly with cil_gen_require, and CIL provides tools to make dependencies optional — a feature that makes your modular hierarchy resilient to change:

CIL optional blocks and macros
(optional httpd_integration
    (typeattributeset cil_gen_require (httpd_t))
    (allow httpd_t myapp_t (tcp_socket (name_connect)))
)
 
(macro daemon_domain ((type domain_t))
    (roletype system_r domain_t)
    (allow domain_t self (process (sigchld)))
)

An optional block contains rules that only activate if their dependency is satisfied — if the module providing httpd_t isn't installed, the block is skipped without error, and when that module arrives later, the policy doesn't need to be rebuilt. Meanwhile, macro lets you define templates: daemon_domain is a "make this a system daemon" recipe called repeatedly with different types. These two mechanisms, optional and macro, are the key to keeping your modules flexible and not fragile against new service additions.

Warning

Uncontrolled inter-module dependencies create "spaghetti". A rule of thumb: each module is one application/one service, and modules must not use each other's internal types except through intentional interfaces (or cil_gen_require). If you find module A allowing module B's types for something that isn't real interaction, that's a design alarm — move that rule to the right place before stacking more.

Maintainability Best Practices

A policy is code — treat it like code, not a one-off artifact:

  1. One module, one service. Module names reflect the service (myapp, not all-apps). This makes debugging easier: semodule -l | grep points straight at the suspect.
  2. Separate declarations and interactions. Declarations of your own types stay in your module; never redefine types owned by another module.
  3. Use attributes and macros. typeattribute and macro (see the examples above) keep you from duplicating the same rules for similar types. One change in a macro, all users change along with it.
  4. Avoid dontaudit to cover problems. dontaudit (episode 8) swallows denials silently; it hides signals, it doesn't solve the root cause. Use it as a documented exception, not a pattern.
  5. Never enable permanent permissive. Permissive is a triage tool (episode 15), not part of the architecture. If a module needs to "work first", install it temporarily, analyze, then remove.
  6. Store in git with CI. CIL modules are text — put them in a repository, then run secilc (and selint if available) as a CI gate. A passing compilation means your module is valid; selint gives style warnings that prevent design bugs early.
  7. Use the distro devel Makefile when practical. Distros provide /usr/share/selinux/devel/Makefile — with make -f /usr/share/selinux/devel/Makefile myapp.pp you get a TE→PP compilation consistent with the distro version, while still keeping the source in git.

Closing

In this episode 17, you've reached the peak of policy authoring: understanding CIL as the intermediate language that's the meeting point of all modern policies, writing type, attribute, and rule declarations in S-expressions, compiling with secilc (with built-in neverallow checks), understanding the modular policy hierarchy (base + modules), distinguishing .pp and CIL modules and when to choose each, managing inter-module dependencies with optional and macro, and applying maintainability best practices for policies that last for years.

The essentials to take with you:

  • CIL is the intermediate language; write directly in CIL for full control and reviewability.
  • secilc compiles and checks neverallow — free validation before production.
  • A production policy is base + modules; one module, one service.
  • .pp for compatibility, CIL for the future — choose per context.
  • optional and macro keep modules flexible against change.
  • A policy is code: git, review, and CI are a must.

With this, you're officially a policy engineer — not just an operator pushing buttons. The policies you build are now modular, documented, and tested. But there's a question that only becomes tangible when scale grows: does all this hierarchy stay efficient when the policy is large and the machines are busy? In the next episode 18, we enter Performance & Scalability: the AVC cache, userspace policy caches, the impact of booleans on decision speed, and strategies for maintaining large policies without sacrificing responsiveness. See you in episode 18!

Learn SELinux - Advanced Policy Development (CIL & Module Layers) | Learn SELinux