---
title: 'Kernel Interfaces and Isolation'
source: 'https://academia.sh/en/courses/kernel-interfaces'
language: en
updated: '2026-08-17T04:41:48+00:00'
license: 'CC BY-SA 4.0'
lessons: 9
outcomes:
    - 'Counts the facts left exposed by the isolation built from namespaces and control groups'
    - "Examines a program's behavior at the system call level and measures its cost"
    - 'Shows with measurement how a container runtime rests on kernel parts'
---

# Kernel Interfaces and Isolation

Counting the surface left open by namespaces, control groups, capabilities and mandatory access control, and how much of that surface the observation tools open back up.

## Isolation Mechanisms

Process, mount, network, user, and machine namespaces, cgroups, privileges, mandatory access control, and how they come together in a container runtime.

### Namespaces

Source: https://academia.sh/en/courses/kernel-interfaces/namespaces

A process's 24 observable facts spread across nine axes; five namespaces close nine of them, leave 15 open, and can fully close only two of the nine axes.

The Linux Network Administration and Troubleshooting course left a promise in its last
lesson: there, a machine had a single network stack; here, multiple stacks are set up on
the same machine, and every interface, every routing table, every rule chain multiplies.
The System Administration course also pointed at the same address in two separate places.
In the service manager lesson, a unit was an entry in the manager's ledger, and isolating
processes from one another was explicitly left to this course; in the resource limits
lesson, the per-process constraint was measured, and the constraint placed on a process
group's **total** was likewise referred here. Both debts carry the same two names:
**namespace** and **cgroup**. This lesson pays the first; the next lesson pays the second.

The way this debt is paid will be unfamiliar. Previous courses counted what a tool could
not see and how many candidates a test eliminated; this course counts **how much an
isolation fails to close**. Its rule is this: an isolation's number is **not the surface it
closes, but the surface it leaves open**; **an isolation whose remaining visibility is not
written down counts as unmeasured.**

#### The Course's Measure and Security Boundary

The measurement places three numbers side by side. The **oracle** is all of a process's
observable facts; since we wrote the fiction ourselves, the whole set is known.
**Isolation** is the number of facts the mechanisms close. The **remaining exposed
surface** is what is left — the facts the process can still see. The third number is this
course's real subject, and it is never skipped in any lesson.

This is a **defensive measurement**, and its boundary is written up front. No escape,
privilege-escalation, bypass, or exploitation procedure is written anywhere in this
course; no working exploit code, proof of concept, or ready-made command sequence is
provided. What is measured is **which fact stays visible**, not how that visibility might
be abused. In exchange, a **narrowing path** is written next to every open surface: if a
gap is counted, the same lesson also shows the mechanism that shrinks it. Least privilege,
access control models, and threat modeling procedure were established in the Introduction
to Cybersecurity and Secure Development Lifecycle courses; only their **kernel-level
counterpart** is measured here.

Every number in the model is a **full count over the fiction**, not a measurement taken on
a real machine. Real commands are **not run** in this lesson; example dumps exist to show
the form, and the identifiers inside them are fiction. No numeric claim comes from an
example dump.

#### Namespace: A Filter Placed Over the Kernel Table

On a machine, the process table is single, the mount point list is single, the network
interface list is single. A namespace does not produce a second table; it places **a
visibility filter over the table that already exists**, and the process looks out from
behind that filter. The same entry can carry two different names, an entry can be present
in one view and absent from another, but it is never deleted.

The types are opened separately and are independent. The **PID namespace** renumbers
process numbers: the first process inside gets number 1, different from its number
outside. The **mount namespace** separates the mount point list. The **network namespace**
separates interfaces, the routing table, and the ports listened on. The **user namespace**
maps identities. The **UTS namespace** separates the machine name. Beyond these, there are
also types that separate inter-process communication objects and the root of the cgroup
tree; this lesson measures five types and names the rest without measuring them.

Which namespaces a process is in stands as symbolic links in the process file system. The
dump below has **not been executed**; the identifiers are fiction, and a real machine
shows different values.

```text
# example dump — not executed
$ ls -l /proc/self/ns
cgroup -> 'cgroup:[40001]'
ipc    -> 'ipc:[40002]'
mnt    -> 'mnt:[40003]'
net    -> 'net:[40004]'
pid    -> 'pid:[40005]'
user   -> 'user:[40006]'
uts    -> 'uts:[40007]'
```

Whether two processes are in the same namespace is understood from the equality of the
identifiers these links show. A namespace **is an object**: it lives as long as a process
is attached to it or it is pinned to a file path, and it vanishes once both end.

A namespace is entered two ways. To create a new namespace, a process detaches itself; to
join an existing namespace, the target's link is opened. The two have separate shell-level
tools:

```text
# example dump — not executed
$ unshare --user --pid --fork --mount-proc --uts --net example-command
$ nsenter --target 8140 --pid --mount --net -- example-command
$ cat /proc/8140/uid_map
         0       1000          1
```

The first line creates new namespaces. There is a reason the user namespace comes first:
it is the only type that lets an unprivileged user open the other types, and the identity
visible inside carries no privilege outside. The mapping table on the third line shows
this — the inside's 0 corresponds to a user outside, and the mapping is written once and
closed. The second line does the opposite: it enters an existing namespace from outside.
This tool is not an isolation mechanism, it is an **observation channel**, and it requires
privilege on the machine; this course's second topic counts how much channels of this kind
puncture isolation.

#### Twenty-Four Facts, Nine Axes

For measurement, what a process can see is reduced to a countable set. The fiction is the
`metrics-collector` process running on a metrics server.

- **IM1** — A process has **24 observable facts**, and each fact belongs to an **axis**:
  process, mount, network, user, machine, resource, kernel, privilege, label. The number
  of axes is **9**.
- **IM2** — **The oracle is all of a process's facts**; the side that built the fiction
  knows all of them.
- **IM3** — An isolation mechanism is a **filter**: it closes a specific axis but can
  **leave something open** on that axis.
- **IM4** — A mechanism closes only a single axis; cross-axis side effects are not
  modeled.
- **IM5** — All numbers are **full counts**; there is no randomness, and no second seed
  is used.
- **IM6** — Facts carry equal weight in terms of observability; one fact being more
  valuable than another is not modeled.

```python
"""M03/K05 shared definition, the parts this lesson uses.

Oracle = all of a process's facts (fiction, known in advance).
Isolation = makes a subset of facts INVISIBLE.
What is measured: REMAINING EXPOSED SURFACE, i.e. the number of facts still visible.
"""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]

# Each namespace closes ONE axis and LEAVES SOMETHING OPEN on that axis.
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
}
FIVE = list(MECHANISMS)


def visible(mechanisms, facts=None):
    """Facts still visible once the mechanisms are applied."""
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


axes = sorted({e for _, e in FACTS})
print("ORACLE  fact:", len(FACTS), "| axis:", len(axes), axes)
print()
print("namespace             in axis  closed  left open  visible")
for m in FIVE:
    g = visible([m])
    n = sum(1 for _, e in FACTS if e == MECHANISMS[m]["axis"])
    print(f"  {m:20s} {n:7d}  {len(FACTS) - len(g):7d}"
          f"  {len(MECHANISMS[m]['left_open']):10d}  {len(g):7d}")
print()
g5 = visible(FIVE)
print("five namespaces together")
print("  visible fact:", len(g5), "| open axis:", len(open_axes(FIVE)),
      open_axes(FIVE))
print("  fully closed axis:",
      [MECHANISMS[m]["axis"] for m in FIVE if not MECHANISMS[m]["left_open"]])
```

```
ORACLE  fact: 24 | axis: 9 ['kernel', 'label', 'machine', 'mount', 'network', 'privilege', 'process', 'resource', 'user']

namespace             in axis  closed  left open  visible
  pid-namespace              3        2           1       22
  mount-namespace            3        2           1       22
  network-namespace          3        3           0       21
  user-namespace             2        1           1       23
  uts-namespace              1        1           0       23

five namespaces together
  visible fact: 15 | open axis: 7 ['kernel', 'label', 'mount', 'privilege', 'process', 'resource', 'user']
  fully closed axis: ['network', 'machine']
```

#### Isolation Is Not Binary, It Is Partial

Three numbers stand side by side. **Oracle:** 24 facts, 9 axes. **Isolation:** five
namespaces together close **9 facts**. **Remaining exposed surface:** **15 facts** and
**7 axes**.

The table's first reading concerns the mechanisms one by one. The PID namespace closes two
of three facts and leaves one open: the process cannot see the list of other processes or
the ancestor chain, but it sees **its own number**. The mount namespace works the same
way; other mounts and the shared temp directory close, **the root filesystem tree itself**
stays visible. The user namespace closes only one of two facts: identity mapping is
separated, **file ownership** stays open.

Only **two of the nine axes fully close**, and those two are the network and UTS
namespaces. The network namespace closes all three facts of its own axis; the UTS
namespace closes its single fact. Why it is these two is not an arbitrary choice of the
model, it has to do with the structure of what they close: the network stack and the
machine name are **external** attributes that do not belong to the process itself. The
process number, the root directory, and file ownership, by contrast, sit within the
process itself; a process cannot run without seeing itself.

This is the shared definition's first reading: **isolation is not binary, it is
partial.** Turning on a mechanism is not enough to count an axis as "closed"; the fact it
leaves open must be written next to every mechanism.

#### Fact Set Sweep

Because the model has no randomness, the measurement cannot be repeated with a second
seed. How much the result depends on the fiction is seen by **sweeping the fact set**: a
fact is added to the list, a fact is removed, and how the exposed surface changes is read.

- **IM7** — The added fact `session-id` belongs to the process axis and is not among what
  the PID namespace leaves open.
- **IM8** — The removed `file-ownership` is the user axis's single fact left open.
- **IM9** — The second removal, `machine-name`, is the machine axis's only fact; once
  removed, the axis drops off the list too.

```python
# --- This block builds on the previous block: FACTS, MECHANISMS, FIVE,
# visible and open_axes come from there.
ADDED = FACTS + [("session-id", "process")]
MISSING_OWNERSHIP = [(o, e) for o, e in FACTS if o != "file-ownership"]
MISSING_MACHINE = [(o, e) for o, e in FACTS if o != "machine-name"]

print("fact set sweep (five namespaces applied)")
print("set                        fact  axis  visible  open axis")
for name, facts in (("base", FACTS),
                     ("+session-id/process", ADDED),
                     ("-file-ownership", MISSING_OWNERSHIP),
                     ("-machine-name", MISSING_MACHINE)):
    g = visible(FIVE, facts)
    e = open_axes(FIVE, facts)
    print(f"  {name:24s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(g):7d}  {len(e):10d}")
```

```
fact set sweep (five namespaces applied)
set                        fact  axis  visible  open axis
  base                       24      9       15           7
  +session-id/process        25      9       15           7
  -file-ownership            23      9       14           6
  -machine-name              23      8       15           7
```

The sweep says two separate things. First: **adding** a fact to a closed axis does not
grow the exposed surface. The process axis grew to four facts with the twenty-fifth fact,
the PID namespace closed all three of them, and the visible fact count stayed at 15.
Second, and more important: **removing the single fact left open closes the whole
axis.** When file ownership drops off the list, the user namespace closes its entire axis,
the open axis count falls from 7 to **6**, and the visible fact count drops to 14.

This shows where the measurement is sensitive: what determines the exposed surface is not
the fact count, but which facts are marked as **left open**. The third row confirms this
from the opposite direction; when the machine name is removed, the axis drops off the
list, the UTS namespace turns into a mechanism that closes nothing, and the visible fact
count is 15 again. A mechanism with nothing to close does not narrow the surface, it only
grows the configuration.

#### The Three Left Open, and Narrowing Paths

Of the 15 facts the five namespaces leave open, three belong directly to this lesson; the
remaining twelve are on the resource, kernel, privilege, and label axes and are the
subject of the next four lessons. A narrowing path is written next to each of the three.

**Its own process number** stays open and cannot be closed; a process cannot receive a
signal or reap a child without knowing its own number. The narrowing here is not hiding
the number, it is **not leaking the number's meaning**: the process file system is
remounted inside, and no path is left open to the outside numbers; which namespace the
numbers written to logs belong to is recorded separately. The same process carrying 1
inside and a different number outside is not a malfunction, it is the rule of
measurement.

**The root filesystem tree** stays open because a process must have a root. The narrowing
path is to shrink **what the root contains**: the root is mounted read-only, writable
space is handed to a separate, temporary mount, and unnecessary device nodes and helper
programs are not placed in it. Of the mount options, `nosuid`, `nodev`, and `noexec` are
this narrowing's three cheapest steps, and they are written in the same place as the
persistent mount entry from the System Administration course.

**File ownership** stays open because identity mapping changes the numbers a process sees
but does not change the ownership fields on disk. The narrowing path is to carry the
mapping through to the file system: the root filesystem tree's ownership is normalized to
the mapped range, and shared directories are either given read-only or not given at all.
This lesson does not stop at counting a surface; every line also leaves behind a
configuration decision that shrinks it.

#### Summary

- This course measures the surface an isolation **leaves open**; an isolation whose
  remaining visibility is not written down counts as unmeasured. The measurement is a
  defensive measurement, and a narrowing path is written next to every open surface.
- A namespace does not produce a second table; it places a **visibility filter** over the
  kernel table that already exists, and the process looks out from behind that filter.
- The oracle is **24 facts and 9 axes**; five namespaces together close **9 facts**, and
  **15 facts** and **7 axes** stay open.
- No namespace fully closes its own axis on its own; the **network and UTS namespaces**
  are the only pair that can. The PID namespace leaves `own-process-number` open, the
  mount namespace `root-filesystem-tree`, the user namespace `file-ownership`.
- The fact set sweep shows where the measurement is sensitive: adding a fact to a closed
  axis does not grow the surface, removing the single fact left open closes the axis and
  drops the open axis count **from 7 to 6**.
- The first half of the debt left to this course by the System Administration and Linux
  Network Administration and Troubleshooting courses is paid; the second half is the
  cgroup.

#### Next Step

Five namespaces narrowed visibility but never limited consumption at all: the process
inside can use the machine's CPU, memory, and I/O bandwidth as if they were its own. The
resource axis therefore stands entirely open with its four facts. The next lesson takes up
the mechanism that closes this axis, the cgroup: it separates resource limiting from
accounting, writes its difference from the per-process constraint measured in the System
Administration course, and counts why closing three of the four resource facts leaves the
`cpu-count` fact open. That single open fact is enough to make every program that sizes
its own thread pool by the machine's CPU count silently miscalculate.

### Control Groups

Source: https://academia.sh/en/courses/kernel-interfaces/control-groups

The cgroup closes three of the resource axis's four facts and leaves the CPU count open; in the six-mechanism bundle the visible fact count drops from 15 to 12, and the open axis count still stands at 7.

The previous lesson wrote that the five namespaces narrowed visibility but never limited
consumption at all. The resource axis's four facts — CPU share, memory limit, used memory,
and CPU count — stood exactly as they were even after the five namespaces were opened. A
namespace is a visibility filter, and visibility does not stop consumption: a process that
sees its own process table can still keep the machine's entire CPU busy.

This lesson takes up the mechanism that closes that axis and pays the second half of the
debt left by the System Administration course. In the service manager lesson, a unit was
an entry in the manager's ledger, and the kernel side of resource accounting was
explicitly left to this course. The question is the same again: does the cgroup close the
resource axis, or does it leave something behind.

#### Tree, Controller, and Two Jobs

A cgroup is a **process group**, and groups form a tree. Every node of the tree is a
directory; when a process's number is written to a node's member file, the process moves
into that group, and its children inherit the group. A process is in exactly one group at
a time; changing groups is a write operation and can be done while the process is running.

**Controllers** operate over the tree: each resource type — CPU, memory, I/O, process
count — is a separate controller. A controller is enabled at one node and applies to the
entire subtree beneath that node. A controller not enabled at an upper node cannot be
enabled below it either; this rule is what makes delegating a branch of the tree to
another manager safe.

Every controller does two separate jobs, and mixing them up produces the wrong
expectation. **Limiting** sets a ceiling or a weight: the ceiling cannot be exceeded, and
the weight decides the share under contention. **Accounting**, on the other hand, limits
nothing, it only counts — memory used, CPU time spent, time spent waiting. A group can
have accounting turned on with no limit set at all; in that case the group is a unit of
measurement, not a constraint.

The path in the cgroup tree can be read by the process itself. There is a separate
namespace type that hides this path, and it was named and passed over in the previous
lesson: the cgroup namespace shifts the root of the path a process sees to its own group.
Hiding the path does not mean hiding the limit; the limit files are still readable.

```text
# example dump — not executed; paths and values are fiction
$ cat /sys/fs/cgroup/metrics.slice/cgroup.controllers
cpu io memory pids
$ cat /sys/fs/cgroup/metrics.slice/cpu.max
20000 100000
$ cat /sys/fs/cgroup/metrics.slice/memory.max
536870912
$ cat /sys/fs/cgroup/metrics.slice/memory.current
331350016
$ nproc
8
```

The first number pair in the dump is a quota: twenty thousand units in a
hundred-thousand-unit window, that is, **0.2 CPU**. The last line gives the machine's CPU
count. The two lines are both true, on the same machine, for the same process, and they do
not contradict each other; they give two separate answers to the same question.
What this lesson measures is exactly the gap between these two lines. The dump has not
been executed, and no numeric claim comes from it.

#### Its Difference from the Per-Process Constraint

The System Administration course measured per-process resource limits: soft and hard
values, inheritance on fork, and a running process's limit not being changeable from the
shell. That lesson is not repeated here, but the cgroup cannot be understood without
writing down the difference between the two mechanisms.

The per-process limit is a ceiling on **a single process**. A hundred processes with the
same limit can consume the machine a hundred times over; the machine runs out without any
of them exceeding their limit. A cgroup, by contrast, looks at the **group's total**:
whatever the number of processes in the group, total consumption is compared against the
ceiling. The second difference is timing. The per-process limit freezes at fork time and
cannot later be changed from outside; a cgroup's limit is written to a file and takes
effect while the process is running. The third difference is accounting: the per-process
limit has no counter, it produces a failure when exceeded; a cgroup counts even when it is
not exceeded.

The two mechanisms do not replace each other, they stack. The same process is subject to
both its own ceiling and its group's ceiling, and which one it hits first depends on the
value of both. In diagnosis, this stacking is a trap: a process's own limit reading does
not show the group's ceiling, and the group's counter does not show a single process's own
ceiling. The two readings are in separate places, and neither substitutes for the other.

- **IM10** — The cgroup is the only mechanism that closes the **resource axis**; the
  resource axis has **4 facts**.
- **IM11** — The cgroup closes **3 facts** of the resource axis and **leaves open** the
  `cpu-count` fact.
- **IM12** — Closing does not mean the fact vanishes; it means the value the process sees
  **stops being the machine's value and becomes the group's value**.
- **IM13** — The measurement does not model per-process limits; the layer measured in the
  System Administration course is not carried over here.

```python
"""Shared definition, the part this lesson uses: 24 facts, 9 axes, and
the cgroup that closes the resource axis. What is measured: REMAINING EXPOSED SURFACE."""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
    "cgroup":            {"axis": "resource", "left_open": ["cpu-count"]},
}
FIVE = ["pid-namespace", "mount-namespace", "network-namespace",
        "user-namespace", "uts-namespace"]


def visible(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


resource = [o for o, e in FACTS if e == "resource"]
open_facts = set(visible(["cgroup"]))
print("ORACLE  fact:", len(FACTS), "| axis:", len({e for _, e in FACTS}))
print("resource axis has", len(resource), "facts")
print()
print("fact                 cgroup")
for o in resource:
    print(f"  {o:20s} {'LEAVES OPEN' if o in open_facts else 'closes'}")
print()
g = visible(["cgroup"])
print("cgroup alone: closed", len(FACTS) - len(g),
      "| left open 1 | visible", len(g),
      "| open axis", len(open_axes(["cgroup"])))
```

```
ORACLE  fact: 24 | axis: 9
resource axis has 4 facts

fact                 cgroup
  cpu-share            closes
  memory-limit         closes
  used-memory          closes
  cpu-count            LEAVES OPEN

cgroup alone: closed 3 | left open 1 | visible 21 | open axis 9
```

Three numbers stand side by side. **Oracle:** 24 facts, 9 axes; 4 facts on the resource
axis. **Isolation:** the cgroup alone closes **3 facts**. **Remaining exposed surface:**
**21 facts** and **9 axes** — the single mechanism drops no axis from the list, because one
fact stays standing on the resource axis.

#### The Fact Left Open: CPU Count

The three facts that close are replaced by the group's own measures. When the process asks
for its CPU share, it sees the group's quota; when it asks for the memory limit, it sees
the group's ceiling; when it asks for used memory, it sees the group's counter. There is no
such substitution for the fourth: **the machine's CPU count shows through unchanged.** A
process with a quota of 0.2 CPU gets the answer eight on a machine with eight CPUs.

The cost of this is not abstract. Most runtimes choose their thread pool, the number of
concurrently running workers, the number of garbage-collector threads, and the connection
pool size **once, at startup**, by looking at the machine's CPU count. A process that opens
a pool forty times the size of its quota does not run faster; the scheduler still gives it
a 0.2-CPU share, and the extra threads are paid for in waiting, context switching, and
memory. The failure is silent too: no limit is exceeded, no error message appears, only
latency grows.

The narrowing path has three steps, and all three correspond directly to this lesson's
table. First, change the source of the sizing decision: read the group's quota instead of
the machine's CPU count; the quota file is already somewhere the process can see. Second,
pass the quota to the process as an explicit value — if the runtime can read a setting, the
number of concurrently running workers is given from configuration and not left to a
guess. Third, place a **CPU set** constraint alongside the quota: when the CPUs a process
can run on are narrowed, the visible count narrows too, and the two answers move closer
together. None of the three fully closes the fact; they reduce the open surface from a
fact to a **miscalculation**.

#### Who Writes the Limit

Writing to cgroup files by hand is rarely the right path, because another party manages the
tree. The service manager opens a node for every unit, groups units into slices, and
translates the resource settings in a unit file into these nodes' files. A value written by
hand is lost when the manager rebuilds the tree; the durable place is the unit definition.

```text
# example unit file fragment — not executed
[Service]
CPUQuota=20%
AllowedCPUs=2-3
MemoryMax=512M
MemoryAccounting=yes
TasksMax=64
```

The first two lines in this fragment touch directly on the gap this lesson measures: the
first writes the quota, the second narrows the CPUs the process can run on. The fourth line
only turns on accounting and limits nothing. The settings' names and syntax vary by service
manager; what is shown here is not the form but the **distinction**: the line that sets a
limit and the line that only counts are separate lines.

A branch of the tree can be **delegated** to another manager. Delegating means granting the
authority to open nodes and move processes under that branch; the delegate divides its own
subtree however it likes but cannot rise above the ceiling above it. This is exactly how a
container runtime works, and it is measured in the fifth lesson. Delegation's boundary must
also be written down: if the number of nodes opened in the delegated branch and the number
of processes moved into those nodes are not bounded from above, the tree keeps growing
while the ceiling holds.

Accounting itself is a surface that faces this course's second topic. In place of the three
facts it closes, the cgroup substitutes the group's own measures, and these measures are
**readable** files: the CPU time the group has spent, the memory it currently holds, how
many times it has hit the ceiling. This is not a flaw in isolation, it is a requirement of
operation — whether a limit holds can only be known by counting. But the same numbers are
also read by the process inside, and they carry information about the group's load. This
course's second topic takes up reading paths of this kind as an **observation channel** and
counts how much each one punctures isolation.

The decision made when a memory ceiling is hit also stays inside the group: the kernel
picks one of the group's **own** processes that exceeded the ceiling. This is its
difference from the machine-wide memory exhaustion measured in the System Administration
course, and it matters in diagnosis — a death inside a group does not mean the machine's
memory is exhausted. Because the sum of group limits can be set larger than the machine's
memory, the two situations can be seen separately on the same machine.

#### The Growing Bundle and Fact Set Sweep

Throughout the course, mechanisms are added onto the same process, and how the surface
narrows is counted. The previous lesson ended with five namespaces; this lesson adds the
sixth mechanism. The sweep is again done on the fact set, because the model has no
randomness and no second seed.

- **IM14** — The added fact `io-share` belongs to the resource axis and is not among what
  the cgroup leaves open.
- **IM15** — The removed `cpu-count` is the resource axis's single fact left open.
- **IM16** — Bundle order is binding: five namespaces, then the cgroup.

```python
# --- This block builds on the previous block: FACTS, MECHANISMS, FIVE,
# visible and open_axes come from there.
BUNDLE = FIVE + ["cgroup"]
print("the bundle growing through the course")
print("stage                      mechanism  visible  open axis")
for name, d in (("five namespaces", FIVE), ("+cgroup", BUNDLE)):
    print(f"  {name:24s} {len(d):7d}  {len(visible(d)):7d}  {len(open_axes(d)):10d}")
print()
ADDED = FACTS + [("io-share", "resource")]
MISSING = [(o, e) for o, e in FACTS if o != "cpu-count"]
print("fact set sweep (six mechanisms applied)")
print("set                        fact  axis  visible  open axis  resource open")
for name, facts in (("base", FACTS), ("+io-share", ADDED),
                     ("-cpu-count", MISSING)):
    g = visible(BUNDLE, facts)
    e = open_axes(BUNDLE, facts)
    ro = sum(1 for o, ax in facts if ax == "resource" and o in set(g))
    print(f"  {name:24s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(g):7d}  {len(e):10d}  {ro:11d}")
```

```
the bundle growing through the course
stage                      mechanism  visible  open axis
  five namespaces                5       15           7
  +cgroup                        6       12           7

fact set sweep (six mechanisms applied)
set                        fact  axis  visible  open axis  resource open
  base                       24      9       12           7            1
  +io-share                  25      9       12           7            1
  -cpu-count                 23      9       11           6            0
```

The first table is the first payment on the course's second claim. The sixth mechanism
drops the visible fact count from 15 to **12** — a gain of three facts — but **the open
axis count stays at 7**. The resource axis did not close, it only thinned. Adding a
mechanism reduces the fact count, not the axis count.

The sweep confirms the same pattern. When the twenty-fifth fact is added to the resource
axis, the cgroup closes it too, and the exposed surface stays at 12: adding a fact to a
closed axis does not grow the surface. By contrast, when the single fact left open is
removed from the list, the axis closes completely, the visible fact count drops to **11**,
and the open axis count to **6**. Where the measurement is sensitive is the same again:
what determines the exposed surface is not the fact count on the axis, but **which fact is
left open.**

Every number in the model is a full count over the fiction; it is not a measurement taken
on a machine. How many facts the resource axis carries on a real system varies with which
controllers are enabled and how the process file system is mounted.

#### Summary

- A cgroup is a process group; groups form a tree, controllers operate over them, and
  every controller does two separate jobs: **limiting** and **accounting**.
- It differs from the per-process resource limit on three points: it looks at the
  **group's total**, it can be **changed** while the process is running, and it **counts**
  even when not exceeded.
- The resource axis has **4 facts**; the cgroup closes **3** of them, **leaves open** the
  `cpu-count` fact, and on its own leaves **21** of the 24 facts visible.
- The fact left open produces a concrete miscalculation: a process with a quota of a fifth
  of a CPU sees the machine's CPU count and sizes its pools accordingly. The narrowing path
  is reading the quota, giving the concurrent worker count from configuration, and
  narrowing the CPU set.
- In the six-mechanism bundle, the visible fact count drops **from 15 to 12**, and the
  **open axis count stays at 7**; in the sweep, removing the single fact left open closes
  the axis and the surface drops to **11**.

#### Next Step

The resource axis thinned, but what a process can do has still not been measured.
Narrowing visibility and limiting consumption does not determine which **privileged
operation** a process can request from the kernel: a process inside the same group can
still attempt to change network configuration, open a mount point, or touch another user's
file. The next lesson takes up the superuser privilege's division into separately
grantable pieces, measures capability sets and dropping, and counts why only one of the
privilege axis's two facts closes. The fact left open is file permissions, and the reason
is unexpected: a capability dropped from a process does not change what is written on the
file.

### Capabilities

Source: https://academia.sh/en/courses/kernel-interfaces/capabilities

Superuser privilege is divided into separately grantable pieces; capability dropping closes only one of two facts and file permissions stay open, and in the seven-mechanism bundle the visible fact count drops to 11.

The previous two lessons narrowed **what a process sees** and **how much it consumes**.
The third question belongs to another axis: which **privileged operation** a process can
request from the kernel. A process that sees its own process table, with its quota
trimmed, can still attempt to open a mount point, change network configuration, or touch
another user's file. Whether these requests are granted has nothing to do with visibility
or consumption.

This lesson measures the mechanism that makes that decision. The catalog's heading was
"Capabilities" and the lesson keeps that name: this curriculum renders `capability` as a
distinct term from `privilege`, and this single form is used throughout the course.

#### The Fragmentation of Superuser Privilege

The classic model is binary: a process either belongs to user zero and is exempt from
every kernel check, or it does not and is exempt from none. A thousand privileged
operations all hang on a single flag. A program that wants to listen on a network port
also gets, under this model, the right to load kernel modules and change the ownership of
any file.

A **capability** is this single flag divided into separately grantable pieces. Every
privileged operation class is bound to a capability: binding to a low-numbered port,
opening a raw socket, changing network configuration, changing file ownership, bypassing
file permission checks, raising process priority, changing the system clock, loading a
kernel module, and mounting are separate capabilities. A process can only request the
operations its granted capabilities cover; for the rest, even belonging to user zero is
not enough.

The fragmentation is not even, and it carries a detail this course cares about. Some
capabilities are narrow in scope and open a single operation class; others are broad and,
on their own, cover enough operation classes to crack open the door to other capabilities.
The capability that bundles mounting, namespace management, and a great many
administrative operations together on its own is the best known of these. The rule in
practice is therefore short: **a broad capability is not granted.** When a program
genuinely needs one, moving the needed operation into a separate helper is a narrower
solution than granting that capability to the whole process.

#### Five Sets and the Transition Rule

A process's capabilities are not a single list but **five sets**, and each set has a
separate job. The **effective set** carries the capabilities currently used in checks. The
**permitted set** is the pool the process can pull into the effective set. The
**inheritable set** shows what can be carried across an exec. The **bounding set** is a
ceiling: a capability not in it can never be regained by any route. The **ambient set** is
the bridge that lets inheritable capabilities carry over to the new program.

A separate flag sits above these sets: when the **no-new-privileges** flag is turned on,
the process and all its children cannot gain new capabilities through an exec. The flag is
turned on once and cannot be turned off. Capability dropping being permanent depends on
this flag; dropping done without the flag turned on can be reversed by capabilities
written on a file.

```text
# example dump — not executed; mask values are fiction
$ grep -E '^(Cap|NoNewPrivs)' /proc/self/status
CapInh: 0000000000000000
CapPrm: 000000000000cc00
CapEff: 000000000000cc00
CapBnd: 000000000000cc00
CapAmb: 0000000000000000
NoNewPrivs: 1
```

The masks in the dump are bit sets; every bit corresponds to a capability. What must be
read is not the individual bits but the **relationship between the sets**: if the bounding
set equals the permitted set, no dropping has been done; if the ambient set is empty, no
capability is carried across an exec.

Capabilities sit not only on processes but also on **files**. An executable file can have a
capability marked on it, and when that file runs, the process starts with a capability the
caller did not have. This is a narrow alternative to the old ownership flag: the file
grants not the whole privilege, only the marked capability.

```text
# example dump — not executed
$ getcap /usr/local/bin/metrics-collector
/usr/local/bin/metrics-collector cap_net_bind_service=ep
$ setcap -r /usr/local/bin/metrics-collector
$ find /root-tree -perm /6000 -type f
/root-tree/usr/bin/old-helper
```

The last line is an audit step: it lists the files in the root tree that carry an
ownership flag. If this list is not empty, a capability dropped from the process can come
back through the file. The dumps have not been executed, and no numeric claim comes from
them.

#### Where a Capability Applies and Where It Is Written

Where a capability applies cannot be said without accounting for namespaces. A process
that opens a user namespace starts inside that namespace with a broad capability set; but
that set's scope is **only that namespace and the objects beneath it**. The same
capability does nothing to an object outside the namespace. The meaning of the first
lesson's mapping table is completed here: the user zero visible inside is an ordinary
mapped identity outside, and the boundary of its capabilities is the boundary of the
mapping.

This has two practical consequences. First, "appearing privileged inside" and "being
privileged on the machine" are two separate states, and mixing them up in diagnosis gives
the wrong answer. Second, when access to a resource outside the namespace is required — a
device node, a mount point, a kernel setting — the capability alone is not enough; that
resource must be explicitly brought inside the namespace. As long as it is not brought in,
the exposed surface does not grow.

Capabilities are written by the party that starts the process, and their durable place is
a configuration file.

```text
# example unit file fragment — not executed
[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/data/metrics
```

The first line in this fragment reduces the bounding set to a single capability: no
capability outside this list can ever be regained under this unit. The second line lets
that capability carry across an exec. The third line turns on the flag. The last two lines
touch the fact this lesson leaves open — they make the file system read-only and leave
only the needed directory writable. The settings' names vary by service manager; what does
not vary is the **order**: the ceiling is lowered first, then the single needed capability
is granted back.

A program's genuine capability needs are not found by guessing. The hardening habit works
in reverse: it starts by emptying the bounding set, the program is run in a test
environment, the denied operations are logged, and for every operation that lands in the
log, **a single capability** is granted back. This route earns two things. The
capabilities granted are the ones actually used, and the list turns into a written record
of what the program does; when a version changes, the list growing also becomes a visible
event. Least privilege was established in the Introduction to Cybersecurity course; its
counterpart here is this list.

#### Privilege Axis: Two Facts, One Closure

- **IM17** — The privilege axis has **2 facts**: `capability-set` and `file-permissions`.
- **IM18** — Capability dropping is the only mechanism that closes the capability axis; it
  closes **1 fact** and **leaves open** the `file-permissions` fact.
- **IM19** — The distinction among the five sets is reduced in the model to a single
  fact; this is a deliberate limit of the model, and the sweep tests exactly this limit.
- **IM20** — File capabilities and file permission bits are gathered into the same fact
  in the model.

```python
"""Shared definition, the part this lesson uses: 24 facts, 9 axes, and
capability dropping that closes the capability axis. What is measured: REMAINING EXPOSED SURFACE."""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
    "cgroup":            {"axis": "resource", "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": "privilege", "left_open": ["file-permissions"]},
}
SIX = ["pid-namespace", "mount-namespace", "network-namespace",
       "user-namespace", "uts-namespace", "cgroup"]


def visible(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


open_facts = set(visible(["capability-dropping"]))
print("ORACLE  fact:", len(FACTS), "| axis:", len({e for _, e in FACTS}))
print("capability axis has", sum(1 for _, e in FACTS if e == "privilege"), "facts")
print()
print("fact                 capability dropping")
for o, e in FACTS:
    if e == "privilege":
        print(f"  {o:20s} {'LEAVES OPEN' if o in open_facts else 'closes'}")
print()
g = visible(["capability-dropping"])
print("capability dropping alone: closed", len(FACTS) - len(g),
      "| left open 1 | visible", len(g),
      "| open axis", len(open_axes(["capability-dropping"])))
```

```
ORACLE  fact: 24 | axis: 9
capability axis has 2 facts

fact                 capability dropping
  capability-set       closes
  file-permissions     LEAVES OPEN

capability dropping alone: closed 1 | left open 1 | visible 23 | open axis 9
```

Three numbers stand side by side. **Oracle:** 24 facts, 9 axes; 2 facts on the capability
axis. **Isolation:** capability dropping closes **1 fact**. **Remaining exposed surface:**
**23 facts** and **9 axes**. This is among the least-closing of the mechanisms measured
alone, and the number is not misleading: capability dropping narrows what a process **can
request**, not what it sees.

This is a limit of the measurement and must be written down openly. This course counts
visibility; a mechanism's operational value is not measured by its closed-fact count
alone. Against its single fact in the table, capability dropping closes most of the
privileged operation classes a process could request from the kernel. The two measures
answer separate questions, and neither substitutes for the other.

#### The Fact Left Open: File Permissions

The fact that does not close is in an unexpected place. A capability dropped from a
process **does not change what is written on the file.** If a file in the root tree
carries an ownership flag or a marked capability, when that file runs, the new process's
starting set is computed from the file. The dropping was done on the process, not on the
file system, and the two sides do not know about each other.

The same distinction holds for file permission bits. When the capability that bypasses
permission checks is dropped, the process can no longer access every file; but the
permissions written on the files stay in place, and a world-writable directory stays
writable for a process with dropped capabilities too. Capability dropping is a **request
filter**, not a file system operation.

The narrowing path therefore runs through the file system and has four steps. First, the
root tree is mounted **read-only**; writable space is handed to a separate, narrow mount.
Second, the `nosuid`, `nodev`, and `noexec` options are placed on writable mounts — the
first cuts off gaining a capability through a file, the second producing a device node,
the third running a program from that space. Third, while the root tree is being
prepared, files carrying an ownership flag are searched for and the flag is cleared on the
ones that do not need it; this is the job of the last line in the lesson's example dump.
Fourth, the **no-new-privileges** flag is turned on and the bounding set is dropped; done
together, a dropped capability never comes back through any exec.

What the four have in common is this: none of them is done on the process, all are done in
**the process's world**. The model represents this distinction with a single open fact,
and what closing that fact requires is not a second capability setting but narrowing the
root tree itself.

#### The Growing Bundle and Fact Set Sweep

- **IM21** — Bundle order is binding: five namespaces, the cgroup, then capability
  dropping.
- **IM22** — The added fact `ambient-capabilities` belongs to the capability axis and is
  not among what capability dropping leaves open.
- **IM23** — The removed `file-permissions` is the capability axis's single fact left
  open.

```python
# --- This block builds on the previous block: FACTS, MECHANISMS, SIX,
# visible and open_axes come from there.
SEVEN = SIX + ["capability-dropping"]
print("the bundle growing through the course")
print("stage                      mechanism  visible  open axis")
for name, d in (("six mechanisms", SIX), ("+capability dropping", SEVEN)):
    print(f"  {name:24s} {len(d):7d}  {len(visible(d)):7d}  {len(open_axes(d)):10d}")
print()
ADDED = FACTS + [("ambient-capabilities", "privilege")]
MISSING = [(o, e) for o, e in FACTS if o != "file-permissions"]
print("fact set sweep (seven mechanisms applied)")
print("set                        fact  axis  visible  open axis  privilege open")
for name, facts in (("base", FACTS), ("+ambient-capabilities", ADDED),
                     ("-file-permissions", MISSING)):
    g = set(visible(SEVEN, facts))
    e = open_axes(SEVEN, facts)
    po = sum(1 for o, ax in facts if ax == "privilege" and o in g)
    print(f"  {name:24s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(g):7d}  {len(e):10d}  {po:10d}")
```

```
the bundle growing through the course
stage                      mechanism  visible  open axis
  six mechanisms                 6       12           7
  +capability dropping           7       11           7

fact set sweep (seven mechanisms applied)
set                        fact  axis  visible  open axis  privilege open
  base                       24      9       11           7           1
  +ambient-capabilities      25      9       11           7           1
  -file-permissions          23      9       10           6           0
```

The seventh mechanism drops the visible fact count from 12 to **11**: one fact. The open
axis count still stands at **7**, and this gives the same result for a third time. Adding
a mechanism narrows the surface, not the axis; something stays open on every axis.

The sweep also shows the model's limit. When a fact independent of the eighth mechanism is
added to the privilege axis, capability dropping closes it too, and the surface stays at
11: modeling the five sets separately would not have changed the result. By contrast, when
the single fact left open is removed, the axis closes, the surface drops to **10**, and
the open axis count to **6**. The result points the same direction as the previous two
lessons: what determines the surface is not the fact count on the axis, but **the
existence of the fact left open.** Every number in the model is a full count over the
fiction, not a measurement taken on a machine.

#### Summary

- A capability is superuser privilege divided into separately grantable pieces;
  capabilities are not equal in scope, and a **broad capability is not granted**.
- A process has **five capability sets** — effective, permitted, inheritable, bounding,
  and ambient — and above them sits the **no-new-privileges** flag, which is turned on
  once and cannot be turned off.
- The privilege axis has **2 facts**; capability dropping closes **1** of them, **leaves
  open** the `file-permissions` fact, and on its own leaves **23** of the 24 facts
  visible.
- The reason the fact stays open is structural: dropping is done on the process, what is
  written on the files does not change. The narrowing path is in the file system — a
  read-only root, the `nosuid`, `nodev`, and `noexec` options, clearing ownership flags,
  and dropping the bounding set.
- In the seven-mechanism bundle, the visible fact count drops **from 12 to 11**, and the
  **open axis count stays at 7**; in the sweep, removing the single fact left open drops
  the surface to **10** and the axis to **6**.

#### Next Step

Capability dropping narrowed what a process can request from the kernel, but it never
asked **whom** the request is directed at. Permission checking still rests on decisions
made by the file's owner: if a directory is marked world-writable, a process with dropped
capabilities writes there too. The next lesson takes up the layer that takes this decision
out of the owner's hands and binds it to a policy. Subject and object are given an access
label, every access between them is made explicitly allowed by a policy rule, and access
is denied if there is no rule. What is measured is two-sided: the fact the policy closes,
and the policy's **own** exposed surface, that is, the number of accesses allowed when not
needed.

### Mandatory Access Control

Source: https://academia.sh/en/courses/kernel-interfaces/mandatory-access-control

Policy-based control closes one of the label axis's two facts and leaves the access label open; of 36 access pairs, 8 are required, while a coarse policy leaves 10 excess permissions and a narrow policy leaves 1.

The previous lesson narrowed which privileged operation a process can request from the
kernel, and counted something left open on the file side. Beneath what stays open lies a
more general rule: who can access a file is decided by that file's **owner**. If the owner
marks a directory world-writable, a process with dropped capabilities writes there too.
Dropping filters the request, it does not write the permission itself.

This lesson takes up the layer that takes the decision out of the owner's hands and binds
it to a policy, and measures two separate surfaces: the fact the policy closes, and the
**policy's own exposed surface**, that is, the number of accesses allowed when not needed.
The lesson teaches **writing** policy; disabling the policy is not offered as a solution
anywhere in this course, and its measure is counted at the end too.

#### From Discretionary Control to Mandatory Control

File permissions and ownership are a **discretionary** model: the object's owner has the
authority to widen access, and does not ask anyone when widening it. The model is common
because it is simple, and that is also where its weakness lies — a single wrong marking
opens an entire directory, and there is no written place across the system showing which
subject can access which object.

**Mandatory access control** carries the decision to a central policy. In the kernel,
there is a layer of hooks placed at the points access checks pass through; a security
module attaches to these hooks and makes a second decision on every access request. The
rule is: the classic permission check runs **first**, and if it passes, the policy check
runs. The two are chained in series, and **an access that does not pass both does not
happen.** The policy cannot widen the permission the owner grants; it can only narrow it.

The policy has two objects. The **access label** is a mark attached to the subject and the
object; a process carries a label, a file carries a label. A **policy rule** forms a bond
between two labels and an operation class. The default behavior is to **deny**: every
access not explicitly allowed by a rule is denied. This is the exact opposite of the
discretionary model, and it makes writing the policy mandatory — without a rule, the
program does not run.

```text
# example dump — not executed; label fields are fiction
$ ls -Z /data/metrics
system_u:object_r:metrics_data_t:s0  raw.dat
system_u:object_r:metrics_log_t:s0  log.jsonl
$ ps -Z -p 8140
system_u:system_r:metrics_t:s0  8140 metrics-collector
```

The label has fields, and the decision mostly rests on just one of them, the type field.
The process is of type `metrics_t`, the metrics data is `metrics_data_t`, the log file is
`metrics_log_t`. The policy counts the allowed accesses among these types.

#### Two Families and How a Rule Is Written

Security modules fall into two families, and the split is about how the object is named.
The **label-based** family keeps the access label on the object itself: the label is in
the file's metadata, and it travels with the file even if the file is moved or renamed.
The **path-based** family, by contrast, names the object by its file path and writes rules
over path patterns. The first is resilient to moves and turns labeling into a separate
job; the second's syntax is shorter, and the rule's scope can change when the same file is
accessed by another path.

```text
# example policy fragment — not executed
allow metrics_t metrics_data_t : file { read write getattr };
allow metrics_t metrics_log_t : file { append getattr };
allow metrics_t metrics_config_t : file { read getattr };
```

```text
# example path-based profile fragment — not executed
/usr/local/bin/metrics-collector {
  /data/metrics/** rw,
  /data/metrics/log.jsonl a,
  /etc/metrics/config.toml r,
  deny /data/billing/** rwx,
}
```

Both forms of syntax say the same three things: subject, object, and operation class. The
dumps have not been executed, and no numeric claim comes from them. Rule syntax varies by
security module; what this lesson measures is not syntax, it is **how many accesses the
rules allow**.

In the label-based family, there is a second written piece alongside the rules, and the
policy silently falls apart when it is forgotten: **transition rules**. A newly created
file by default takes the type of the directory it is in; if the desired type is something
else, this is written with a **type transition** rule. When it is not written, the log
file the metrics process produces is born with the data type, the log rules do not apply
to it, and no one notices a thing. The same gap opens for files restored from backup or
copied from another machine: the label is metadata, and not every transfer method carries
it. This is why part of writing a policy is also the **relabeling** step — the root tree or
the data directory is marked from scratch according to the written labeling rules. The
path-based family has no such step; in exchange, it has the problem of falling outside a
rule's scope when the same file is accessed by a second path.

#### Label Axis: Two Facts, One Closure

- **IM24** — The label axis has **2 facts**: `access-label` and `policy-rules`.
- **IM25** — The mandatory label is the only mechanism that closes the label axis; it
  closes **1 fact** and **leaves open** the `access-label` fact.
- **IM26** — The two module families are represented in the model by a single mechanism.
- **IM27** — Bundle order is binding: five namespaces, the cgroup, capability dropping,
  the mandatory label. When the eighth mechanism is added, the bundle takes the name
  **strict bundle**.

```python
"""Shared definition, the part this lesson uses: 24 facts, 9 axes, and
the mandatory label that closes the label axis. What is measured: REMAINING EXPOSED SURFACE."""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
    "cgroup":            {"axis": "resource", "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": "privilege", "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": "label", "left_open": ["access-label"]},
}
SEVEN = ["pid-namespace", "mount-namespace", "network-namespace",
         "user-namespace", "uts-namespace", "cgroup", "capability-dropping"]
STRICT = SEVEN + ["mandatory-label"]


def visible(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


open_facts = set(visible(["mandatory-label"]))
print("ORACLE  fact:", len(FACTS), "| axis:", len({e for _, e in FACTS}))
print("label axis has", sum(1 for _, e in FACTS if e == "label"), "facts")
print()
for o, e in FACTS:
    if e == "label":
        print(f"  {o:20s} {'LEAVES OPEN' if o in open_facts else 'closes'}")
g = visible(["mandatory-label"])
print("mandatory label alone: closed", len(FACTS) - len(g),
      "| visible", len(g), "| open axis", len(open_axes(["mandatory-label"])))
print()
print("stage                      mechanism  visible  open axis")
for name, d in (("seven mechanisms", SEVEN), ("+mandatory label (strict)", STRICT)):
    print(f"  {name:24s} {len(d):7d}  {len(visible(d)):7d}  {len(open_axes(d)):10d}")
print()
ADDED = FACTS + [("policy-version", "label")]
MISSING = [(o, e) for o, e in FACTS if o != "access-label"]
print("fact set sweep (strict bundle applied)")
print("set                   fact  axis  visible  open axis")
for name, facts in (("base", FACTS), ("+policy-version", ADDED),
                     ("-access-label", MISSING)):
    print(f"  {name:19s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(visible(STRICT, facts)):7d}  {len(open_axes(STRICT, facts)):10d}")
```

```
ORACLE  fact: 24 | axis: 9
label axis has 2 facts

  access-label         LEAVES OPEN
  policy-rules         closes
mandatory label alone: closed 1 | visible 23 | open axis 9

stage                      mechanism  visible  open axis
  seven mechanisms               7       11           7
  +mandatory label (strict)       8       10           7

fact set sweep (strict bundle applied)
set                   fact  axis  visible  open axis
  base                  24      9       10           7
  +policy-version       25      9       10           7
  -access-label         23      9        9           6
```

Three numbers stand side by side. **Oracle:** 24 facts, 9 axes. **Isolation:** the
mandatory label closes **1 fact**, and the strict bundle closes **14 facts** in total.
**Remaining exposed surface:** **10 facts** after the eighth mechanism, and **7 axes**
again. Eight mechanisms have been added, and the open axis count has not changed since the
fourth.

The fact left open is `access-label`, and the reason is structural: the policy attaches a
label to the subject, and the subject can read its own label. Hiding the label would make
the policy unworkable, because the label is the decision's input. The narrowing path is
therefore not hiding the label, it is making sure **the label carries no hidden
information at all**: type names announce role and purpose, not identity, key, tenant
name, or location. The second step is limiting the policy source itself — the rule tree is
not placed in the process's root tree; the only thing the process can read is its own
label, not the whole set of rules. The sweep confirms this: when the label axis's single
fact left open is removed, the axis closes, the surface drops to **9**, and the open axis
count to **6**.

#### The Policy's Own Exposed Surface

When a policy is written, a second measurement is born. Throughout the course we counted
the exposed surface over kernel facts; the same question can be asked at the policy level
too: **how many accesses were allowed when not needed.** The fiction is three subjects,
four objects, and three actions on the metrics server.

- **IM28** — Three subjects, four objects, and three actions produce **36 access pairs**.
- **IM29** — **The oracle is the accesses the workload genuinely needs**; the side that
  built the fiction knows this set, and it consists of **8 accesses**.
- **IM30** — The policy's exposed surface is its **excess permission** count: accesses
  allowed but not needed. **Missing permission**, by contrast, shows broken work.

```python
"""Policy-level exposed surface: number of accesses allowed but NOT NEEDED."""
SUBJECTS = ("metrics", "billing", "maintenance")
OBJECTS = ("metrics-data", "billing-data", "log", "config")
ACTIONS = ("read", "write", "execute")

# Oracle: the accesses the workload GENUINELY needs.
REQUIRED = {
    ("metrics", "metrics-data", "read"), ("metrics", "metrics-data", "write"),
    ("metrics", "log", "write"), ("metrics", "config", "read"),
    ("billing", "billing-data", "read"), ("billing", "billing-data", "write"),
    ("billing", "log", "write"), ("maintenance", "log", "read"),
}


def no_policy(s, o, a):
    """No mandatory control: the decision is only in the discretionary layer."""
    return True


def coarse(s, o, a):
    """A broad rule written at the type level."""
    return o.startswith(s) or o == "log" or (o == "config" and a == "read")


def narrow(s, o, a):
    """The required accesses, plus config read for maintenance."""
    return (s, o, a) in REQUIRED or (s, o, a) == ("maintenance", "config", "read")


ALL = [(s, o, a) for s in SUBJECTS for o in OBJECTS for a in ACTIONS]
print("total access pairs:", len(ALL), "| oracle (required access):", len(REQUIRED))
print()
print("policy            allowed  satisfied  EXCESS PERMISSION  missing permission")
for name, f in (("no policy", no_policy), ("coarse policy", coarse),
                ("narrow policy", narrow)):
    allowed = {t for t in ALL if f(*t)}
    print(f"  {name:15s} {len(allowed):5d}  {len(REQUIRED & allowed):10d}"
          f"  {len(allowed - REQUIRED):10d}  {len(REQUIRED - allowed):10d}")
```

```
total access pairs: 36 | oracle (required access): 8

policy            allowed  satisfied  EXCESS PERMISSION  missing permission
  no policy          36           8          28           0
  coarse policy      18           8          10           0
  narrow policy       9           8           1           0
```

Three rows carry the entire tension of writing a policy. With no policy, all 36 accesses
pass through the policy layer, and **28 of them are unnecessary**. A coarse policy written
at the type level brings this down to **10**; missing permission is zero on all three
rows, meaning the work runs in all three. A narrow policy brings excess permission down to
**1**, and the work still runs. The difference between them is the writing effort: the
coarse policy is written with three rule lines, the narrow policy with nine.

**The measure is not how many rules a policy contains, but how many unnecessary accesses
it leaves standing.** This is the policy-level counterpart of the course's rule, and it is
said in the same sentence: a policy whose remaining permissions are not written down
counts as unmeasured.

#### How a Policy Is Written

The required access list is not found by guessing. The writing order works in reverse: in
a test environment the policy is put into **permissive mode** — rules are evaluated and
violations are logged, but access is not blocked — the workload is run from start to
finish, and a single rule is written for every record that lands in the log.

```text
# example denial record — not executed
type=access-denied action={ write } process=8140 object="raw.dat" class=file
  subject=system_u:system_r:metrics_t:s0  target=system_u:object_r:billing_data_t:s0
```

The three fields the record carries are the rule's three fields: subject type, target
type, operation class. When writing the rule, turning the record into a rule as-is is easy
but wrong; the record points to a single file, the rule covers a type. The question to ask
before writing is: is this access genuinely needed, or is it a mislabeled file. In the
second case, the correct fix is not adding a rule, it is moving the file to the correct
type.

Permissive mode has a cost, and it can be stated with this lesson's numbers: in that mode
the `policy-rules` fact does not close, the strict bundle behaves like seven mechanisms
instead of eight, and the exposed surface is **11 facts instead of 10**. Permissive mode is
a development step, not an operating posture; permissive mode left on in production
corresponds, in the measurement, to losing one mechanism. Disabling the policy entirely is
not offered as a solution in this course — the first row of the table above gives, in
numbers, what disabling it means: **28 excess permissions**. If a rule breaks a workflow,
the correct response is not removing the policy, it is writing a narrow rule for the
single broken access or fixing the object's type.

Every number in the model is a full count over the fiction; it is not a measurement taken
on a real machine, and the number of access pairs in a real policy is far larger.

#### Summary

- Mandatory access control takes the decision away from the object's owner and carries it
  to a central policy; it works **in series** with the classic permission check, and its
  default behavior is to **deny**.
- The policy has two objects: the **access label** attached to the subject and the object,
  and the **policy rule** that binds two labels to an operation class. The two module
  families name the object by label or by path.
- The label axis has **2 facts**; the mandatory label closes **1** of them, leaving the
  `access-label` fact open. The strict bundle closes **14 facts** with eight mechanisms;
  **10 facts** and **7 axes** stay open.
- There is an exposed surface at the policy level too: of 36 access pairs, **8** are
  required, while excess permission is **28** with no policy, **10** with a coarse policy,
  and **1** with a narrow policy; missing permission is zero in all three.
- A policy is written from denials collected in permissive mode; in permissive mode
  `policy-rules` does not close, and the exposed surface is **11 facts instead of 10**.
  Disabling the policy's counterpart in the measurement is **28 excess permissions**.

#### Next Step

Four lessons measured four mechanism families separately: namespaces narrowed visibility,
the cgroup narrowed consumption, capability dropping narrowed privilege, the mandatory
label narrowed access. On a real system these are not turned on one by one; a runtime
reads them all from a single definition and applies them at the same time. The next lesson
measures that combination: it places a commonly used six-mechanism bundle next to a strict
eight-mechanism bundle side by side, counts how many facts adding two mechanisms narrows
the surface by, and shows why the open axis count stays at seven. The number to be seen
there is smaller than expected.

### Container Runtime

Source: https://academia.sh/en/courses/kernel-interfaces/container-runtime

The common six-mechanism bundle leaves 12 facts, the strict eight-mechanism bundle leaves 10: adding two mechanisms narrows the surface by only 2 facts, and the open axis count stands at 7 after the fourth mechanism.

Four lessons measured four mechanism families one by one: namespaces narrowed visibility,
the cgroup narrowed consumption, capability dropping narrowed privilege, the mandatory
label narrowed access. Each left something open on its own axis, and a narrowing path was
written next to each. On a real system, though, these mechanisms are not turned on one by
one. A program reads a definition file, applies every mechanism written in the file in the
correct order, and starts the process. That program's name is the **container runtime**.

This lesson treats the runtime as a **bundle** of mechanisms and asks a single question:
how much does adding a mechanism narrow the surface. Container images, the layer model,
placement, scaling, and orchestration were measured in the DevOps curriculum's Containers
and Container Orchestration courses; they are not repeated. The subject here is only
**the parts the kernel provides** and how a runtime brings them together.

#### What the Runtime Does

The runtime takes as input a **bundle** made of a directory and a definition file. The
directory is the process's root filesystem tree; the definition file writes which
mechanisms open with which settings. The runtime interprets this file, translates it into
kernel calls, and starts the process. Its job is exactly that: it does not invent a new
isolation type, it applies the existing ones in order.

```text
# example runtime definition (abbreviated, field names are examples)
# — not executed
process:
  command: /usr/local/bin/metrics-collector
  uid: 10001
  capability-ceiling: [CAP_NET_BIND_SERVICE]
  no-new-privileges: yes
  access-label: system_u:system_r:metrics_t:s0
root:
  path: root-tree
  read-only: yes
mounts:
  - target: /data/metrics
    options: [rw, nosuid, nodev, noexec]
namespaces: [process, mount, network, machine, user, ipc, cgroup]
id-mapping:
  - inside: 0
    outside: 100000
    size: 65536
cgroup:
  path: /metrics.slice/metrics-collector
  cpu-quota: 20000/100000
  memory-ceiling: 536870912
```

Every line in the definition corresponds to one of the previous four lessons. The field
names vary by specification, and the dump has not been executed; no numeric claim comes
from it.

**Order is binding**, and the wrong order leaves a silent gap. The user namespace opens
first, because it is the only type that lets an unprivileged user open the other types,
and the identity mapping must be written before it closes. The cgroup is prepared before
the process is started, or the first seconds pass unbounded. The root tree is changed
**after** the process moves into its own mount namespace; changing it first pollutes the
machine's mount list. Capability dropping and the no-new-privileges flag are left for
last, right before the exec; done early, the preparation steps fail, done late, the
dropping is never applied.

```text
# example dump — not executed
$ runtime create metrics-01 --bundle ./bundle
$ runtime start  metrics-01
$ runtime state  metrics-01
$ runtime kill   metrics-01 TERM
$ runtime delete metrics-01
```

The lifecycle has five steps, and **create and start are separate**. Create sets up every
mechanism and holds the process before it starts; start only ends the hold. The reason for
the split is operational: it allows the configuration to be verified after all isolation
is set up, while the process has not yet run a single command.

The runtime itself is also a process and needs privilege to set up the mechanisms. There
are two operating modes. In privileged mode, the runtime runs as a privileged service,
opens every mechanism directly, and stays outside the isolation it builds. In rootless
mode, the chain starts with a user namespace: the runtime first opens a user namespace,
writes the identity mapping, and sets up the other mechanisms from inside that namespace.
The second mode closes no additional fact in this course's measurement, but it narrows the
blast radius in the event of a flaw — because the runtime's own capabilities are also
bounded by the mapping.

A boundary that stays outside the bundle must also be written down openly. All of these
mechanisms share **a single kernel**; the runtime separates processes from one another, it
does not duplicate the kernel. If a separate kernel is wanted, what is needed is a
different technology class, and it is not the subject of this course. In the
measurement's last section, why the kernel axis never closes comes from exactly this
sharing.

#### Two Bundles

- **IM31** — **The common bundle is six mechanisms**: the process, mount, network, and
  UTS namespaces, the cgroup, capability dropping. The user namespace and the mandatory
  label are not in this bundle.
- **IM32** — **The strict bundle is eight mechanisms**: the user namespace and the
  mandatory label are added to the common bundle.
- **IM33** — The bundles differ only in their mechanism list; the fact set, the axes, and
  the facts left open are the same as in the shared definition.
- **IM34** — The model counts eight mechanisms. The system call filter a real runtime
  applies, and the step of masking some paths of the process file system, are **not
  counted as mechanisms in the model**; both are written below as narrowing paths.

```python
"""Shared definition: isolation is a filter. A container runtime is a
BUNDLE of mechanisms. What is measured: REMAINING EXPOSED SURFACE."""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
    "cgroup":            {"axis": "resource", "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": "privilege", "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": "label", "left_open": ["access-label"]},
}
COMMON = ["pid-namespace", "mount-namespace", "network-namespace",
          "uts-namespace", "cgroup", "capability-dropping"]
STRICT = COMMON + ["user-namespace", "mandatory-label"]


def visible(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


print("ORACLE  fact:", len(FACTS), "| axis:", len({e for _, e in FACTS}))
print()
print("bundle    mechanism  closed  visible  open axis")
for name, d in (("bare", []), ("common", COMMON), ("strict", STRICT)):
    g = visible(d)
    print(f"  {name:8s} {len(d):7d}  {len(FACTS) - len(g):7d}"
          f"  {len(g):7d}  {len(open_axes(d)):10d}")
print()
print("difference between the two bundles:", [d for d in STRICT if d not in COMMON])
print("facts it makes a difference to     :",
      sorted(set(visible(COMMON)) - set(visible(STRICT))))
print("the two mechanisms narrow the surface by",
      len(visible(COMMON)) - len(visible(STRICT)), "facts")
```

```
ORACLE  fact: 24 | axis: 9

bundle    mechanism  closed  visible  open axis
  bare           0        0       24           9
  common         6       12       12           7
  strict         8       14       10           7

difference between the two bundles: ['user-namespace', 'mandatory-label']
facts it makes a difference to     : ['policy-rules', 'user-id-mapping']
the two mechanisms narrow the surface by 2 facts
```

Three numbers stand side by side. **Oracle:** 24 facts, 9 axes. **Isolation:** the common
bundle closes **12 facts**, the strict bundle **14 facts**. **Remaining exposed surface:**
**12 facts** in the common bundle, **10 facts** in the strict bundle; **7 axes** in both.

This is the strongest payment on the course's second claim. Going from the common bundle
to the strict bundle grows the mechanism count by a third, adds two separate mechanisms,
and noticeably increases configuration and operational load — and it narrows the surface
by only **2 facts**. The facts that narrow are `user-id-mapping` and `policy-rules`. The
open axis count does not change at all.

The result does not mean the two mechanisms are worthless, and it should not be read that
way. The user namespace narrows the scope of privilege by closing identity mapping; the
mandatory label, as counted in the previous lesson, binds 28 of 36 access pairs to policy.
The measurement does not count these; it only counts **visibility**. What it says is
narrow and precise: **on the visibility side, the two mechanisms' gain is two facts.**

The measurement's practical use also comes from this. When deciding whether to tighten a
bundle, the question to ask should not be "how many mechanisms did I turn on" but "which
fact is still visible." The first question rewards the length of the configuration; the
second shows where the gain stops. Written down, the difference between the two bundles is
only two lines, and those two lines can be weighed against the cost of tightening.

#### As Mechanisms Are Added

Comparing the bundles end to end does not show where the gain accumulates. When the same
strict bundle is built mechanism by mechanism, the curve becomes readable.

- **IM35** — The order of addition is the order in the strict bundle's list.
- **IM36** — The fact added in the sweep, `kernel-build-options`, belongs to the kernel
  axis; the removed `clock` is on the same axis too.

```python
# --- This block builds on the previous block: FACTS, MECHANISMS, COMMON, STRICT,
# visible and open_axes come from there.
print("as mechanisms are added (in strict-bundle order)")
for i in range(len(STRICT) + 1):
    added = STRICT[i - 1] if i else "-"
    print(f"  {i} mechanism ({added:20s}) visible {len(visible(STRICT[:i])):2d}"
          f"  open axis {len(open_axes(STRICT[:i]))}")
print()
g = set(visible(STRICT))
print("left open after the strict bundle:", len(g), "facts (by axis)")
for e in open_axes(STRICT):
    o = [x for x, ax in FACTS if ax == e and x in g]
    print(f"  {e:10s} {len(o)}  {', '.join(o)}")
print()
ADDED = FACTS + [("kernel-build-options", "kernel")]
MISSING = [(o, e) for o, e in FACTS if o != "clock"]
print("fact set sweep (strict bundle applied)")
print("set                             fact  axis  visible  open axis")
for name, facts in (("base", FACTS), ("+kernel-build-options", ADDED),
                     ("-clock", MISSING)):
    print(f"  {name:29s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(visible(STRICT, facts)):7d}  {len(open_axes(STRICT, facts)):10d}")
```

```
as mechanisms are added (in strict-bundle order)
  0 mechanism (-                   ) visible 24  open axis 9
  1 mechanism (pid-namespace       ) visible 22  open axis 9
  2 mechanism (mount-namespace     ) visible 20  open axis 9
  3 mechanism (network-namespace   ) visible 17  open axis 8
  4 mechanism (uts-namespace       ) visible 16  open axis 7
  5 mechanism (cgroup              ) visible 13  open axis 7
  6 mechanism (capability-dropping ) visible 12  open axis 7
  7 mechanism (user-namespace      ) visible 11  open axis 7
  8 mechanism (mandatory-label     ) visible 10  open axis 7

left open after the strict bundle: 10 facts (by axis)
  kernel     4  kernel-version, kernel-settings, system-load, clock
  label      1  access-label
  mount      1  root-filesystem-tree
  privilege  1  file-permissions
  process    1  own-process-number
  resource   1  cpu-count
  user       1  file-ownership

fact set sweep (strict bundle applied)
set                             fact  axis  visible  open axis
  base                            24      9       10           7
  +kernel-build-options           25      9       11           7
  -clock                          23      9        9           7
```

The shape of the curve is a result on its own. The first four mechanisms drop the visible
fact count from 24 to **16**: eight facts. The last four mechanisms drop it from 16 to
**10**: six facts. The open axis count, though, **never changes again after the fourth
mechanism** — four mechanisms were added from the fifth through the eighth, and the axis
count stayed at 7.

The reason is written in the last table. **Seven of the 10 facts** left open after the
strict bundle are **one apiece** on seven separate axes. Because every mechanism leaves
one fact open on its own axis, that axis never drops off the list. Something is **always
left open** on seven axes, and this does not close by adding mechanisms. This is exactly
the course's second claim: **adding a mechanism reduces facts but not axes.**

The sweep confirms the same structure and gives a different result from the previous
lessons. When the twenty-fifth fact is added to the kernel axis, the surface rises from 10
to **11**: the addition maps directly onto the surface. When a fact is removed, the
surface drops to **9**, but the open axis count stays at **7**. Where does the difference
come from — in the previous lessons, the added fact fell onto a closed axis and the
mechanism closed it too; here, the axis was never closed.

#### The Axis That Never Closes: Kernel

**Four of the 10 open facts** are gathered on a single axis, and that axis's name is
kernel: kernel version, kernel settings, system load, and clock. These are the single
largest block of the exposed surface in the strict bundle, and the reason is not a gap in
the model — **there is no mechanism that closes the kernel axis.** A namespace does not
produce a second kernel; the machine has a single kernel, and every process talks to it.
This is isolation's most fundamental boundary, and it is not overcome by configuration.

By contrast, the axis can be narrowed, and there are three ways. First, **masking or
making read-only** the paths that publish kernel information: in the process's root tree,
these paths are either never visible or not writable. This is what a runtime does by
default, and it is one of the steps not counted as a mechanism in the model. Second,
giving separately the kernel settings that can be split by namespace; not all settings can
be split, but the ones that can stop showing the machine's value. Third, narrowing the set
of calls a process can make to the kernel with a **system call filter**: the filter does
not make a fact invisible, but it reduces the operations that correspond to the visible
fact. None of the three zeroes out the surface; the honest reading of the measurement is:
**something on this axis will always be seen, and the design is made accordingly.**

For the remaining six facts, the narrowing paths were written in the previous lessons:
shrinking the root tree and mounting it read-only, reading the quota and binding pool
sizing to it, clearing ownership flags and mounting the file system with `nosuid`,
normalizing ownership to the mapped range, naming the access label so it carries no hidden
information. All five follow the same pattern: the fact left open is narrowed not on the
process, but in **the process's world**.

#### Summary

- A container runtime does not invent a new isolation type; it is a program that applies
  the mechanisms in a definition file **in the correct order**. Order is binding, and the
  wrong order leaves a silent gap.
- The common six-mechanism bundle leaves **12 facts** open, the strict eight-mechanism
  bundle **10 facts**: adding two mechanisms narrows the surface by only **2 facts**, and
  the facts that narrow are `user-id-mapping` and `policy-rules`.
- The open axis count **stands at 7 after the fourth mechanism** and does not change at
  all when four more mechanisms are added; adding a mechanism reduces facts, not axes.
- Of the 10 facts left open after the strict bundle, **seven are one apiece on seven
  separate axes**; the remaining **four are on the kernel axis**, and no mechanism closes
  that axis.
- The kernel axis cannot be closed, but it can be narrowed: masking the paths that
  publish information, giving namespace-splittable settings separately, and a system call
  filter. Every number in the model is a full count over the fiction, not a measurement.

#### Next Step

This topic counted what isolation can and cannot close; the strict bundle closed 14 of 24
facts and left 10 open. Whether the 14 closed facts have genuinely closed has not been
tested yet. This course's second topic asks exactly that question, and its answer points
in the opposite direction from the first topic: tools that read a process's conversation
with the kernel, sample CPU usage, capture events inside the kernel, and inspect a crashed
process's core afterward **bring back** some of the facts isolation closed. The next
lesson measures the first of these, system call tracing: it counts how many facts come
back and the cost it adds to the run. This course's two topics are each other's opposite,
and it is written openly — **observation punctures isolation.**

## Observation and Debugging

System call tracing, sampling-based profiling, in-kernel tracing, and core dump analysis; the fact each channel brings back and the cost it charges.

### System Call Tracing

Source: https://academia.sh/en/courses/kernel-interfaces/system-call-tracing

The first measure of observation piercing isolation: of the 14 facts the strict bundle closes, 2 return through system call tracing, the open surface climbs from 10 to 12, and the run slows by 34 percent; three of the five facts the channel shows were already visible.

The isolation topic closed with a strict eight-mechanism bundle: of a process's 24 observable
facts, 14 had closed, 10 stayed visible, and something was still open on seven of the nine
axes. The question there was not "how much did we close," it was "what stayed open." This
topic asks the same question in reverse: of the 14 closed facts, how many come back because of
the tools we use to understand the system.

The reversal is not wordplay. Observation tools were not written to pierce isolation; they were
written to show what a process is doing. But showing what a process is doing means showing
exactly what isolation is trying to hide. The topic's two subjects are therefore opposites of
one another, and this lesson opens with the most expensive example of that opposition: system
call tracing.

#### Channel Versus Filter

The common definition has two objects. **An isolation mechanism is a filter:** it closes a
given axis and usually leaves one fact open on that axis. **An observation channel is a
channel:** it brings back some of the closed facts. **The oracle** is all of the process's
facts; because we wrote the mock setup, it is known. What is measured is the **open surface** —
the union of the visible facts and the returned facts.

This lesson's assumptions are these. The channel runs after isolation is set up and from
**outside** the bundle; the observing side is not bound by the observed side's restrictions
(**OD1**). The channel shows only the facts on its own list; it produces no new fact (**OD2**).
When a fact returns, it returns completely; there is no partial visibility (**OD3**). The five
facts system call tracing shows are the list in the code (**OD4**). Channel costs are
**percentages in the model, not a real measurement**; the baseline run is 1000 time units
(**OD5**). The cost is fixed for the run and does not change with the number of calls traced
(**OD6**). Tracing is on **while the process runs**; it is not a channel that looks after the
run has ended (**OD7**).

What the tool sees, on its side, is the sequence of services the process requests from the
kernel: which file was opened, which metadata was read, which clock was queried. The system
call itself was built as a mechanism in the Operating System Concepts course and is not
repeated here.

```text
# example dump , has not been run
$ strace -f -e trace=file,clock -p <process-no>
openat(AT_FDCWD, "/etc/<config-file>", O_RDONLY)         = 3
newfstatat(3, "", {st_mode=S_IFREG|0644, st_uid=0}, 0)   = 0
openat(AT_FDCWD, "/proc/self/mountinfo", O_RDONLY)       = 4
clock_gettime(CLOCK_REALTIME, {tv_sec=<seconds>})        = 0
```

This dump has not been run and the values in it are mock. What is measured is not the dump
itself but **which fact can be read** from it; the code below counts that.

```python
# M03/K05 common definition -- only the parts this lesson uses.
# Isolation is a filter: it closes an axis , leaving one thing open on each.
# The observation channel is a channel: it brings BACK some of the closed facts.
FACTS = [
    ("other-process-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-directory", "mount"),
    ("host-interfaces", "network"), ("host-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("hostname", "host"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("memory-used", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("security-label", "label"), ("policy-rules", "label"),
]
MECHANISM = {
    "pid-namespace":     {"axis": ["process"], "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": ["mount"], "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": ["network"], "left_open": []},
    "user-namespace":    {"axis": ["user"], "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": ["host"], "left_open": []},
    "control-group":     {"axis": ["resource"], "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": ["privilege"], "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": ["label"], "left_open": ["security-label"]},
}
COMMON_BUNDLE = ["pid-namespace", "mount-namespace", "network-namespace",
                 "uts-namespace", "control-group", "capability-dropping"]
STRICT_BUNDLE = COMMON_BUNDLE + ["user-namespace", "mandatory-label"]

# OD4: the facts the system-call-tracing channel shows
TRACE = ["root-filesystem-tree", "other-mounts", "file-ownership", "capability-set", "clock"]
# OD5: channel costs are percentages IN THE MODEL , not a real measurement
CHANNEL_COST = {"system-call-tracing": 34, "sampling-profile": 3,
                "in-kernel-tracing": 6, "core-dump": 0}
BASELINE_TIME = 1000


def visible(mechanisms, facts=FACTS):
    closed = set()
    for d in mechanisms:
        v = MECHANISM[d]
        for e in v["axis"]:
            closed |= {o for o, x in facts if x == e}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def returned(mechanisms, channel, facts=FACTS):
    g = set(visible(mechanisms, facts))
    closed = {o for o, _ in facts} - g
    return sorted(set(channel) & closed)


def surface(mechanisms, channel=(), facts=FACTS):
    g = set(visible(mechanisms, facts))
    back = set(returned(mechanisms, channel, facts))
    return {"visible": len(g), "returned": len(back), "open_surface": len(g | back),
            "open_axes": len({e for o, e in facts if o in (g | back)})}


print("facts:", len(FACTS), "| axes:", len({e for _, e in FACTS}))
g = set(visible(STRICT_BUNDLE))
print("strict bundle: mechanisms", len(STRICT_BUNDLE), "| visible", len(g),
      "| closed", len(FACTS) - len(g))
print()
print("fact shown by channel       axis       status under strict bundle")
for o in TRACE:
    e = dict(FACTS)[o]
    print(f"  {o:26s}{e:11s}{'already visible' if o in g else 'RETURNS'}")
print()
print("bundle          visible  returned  open surface  open axes")
for ad, d in (("plain", []), ("common", COMMON_BUNDLE), ("strict", STRICT_BUNDLE)):
    y = surface(d, TRACE)
    print(f"  {ad:12s} {y['visible']:7d}  {y['returned']:10d}"
          f"  {y['open_surface']:10d}  {y['open_axes']:10d}")
print()
pct = CHANNEL_COST["system-call-tracing"]
ys = surface(STRICT_BUNDLE, TRACE)
print("cost: baseline", BASELINE_TIME, "-> run", BASELINE_TIME * (100 + pct) // 100,
      "| percent", pct)
print("cost per returned fact:", round(pct / ys["returned"], 2), "percentage points")
print("channel costs (model percentages , not a measurement):", CHANNEL_COST)
print()
print("FACT SET SWEEP -- removal (strict bundle + tracing channel)")
returned_falls, visible_falls = [], []
for o, _ in FACTS:
    y = surface(STRICT_BUNDLE, TRACE, [x for x in FACTS if x[0] != o])
    if y["returned"] < ys["returned"]:
        returned_falls.append(o)
    elif y["visible"] < ys["visible"]:
        visible_falls.append(o)
print("  baseline open surface", ys["open_surface"],
      "| changes it", len(returned_falls) + len(visible_falls),
      "| does not", len(FACTS) - len(returned_falls) - len(visible_falls))
print("    lowers returned:", ", ".join(returned_falls), "-> returned 1 , open surface 11")
print("    lowers visible :", len(visible_falls), "facts -> visible 9 , open surface 11")
print()
print("FACT SET SWEEP -- addition (one new fact per axis)")
for e in sorted({e for _, e in FACTS}):
    y = surface(STRICT_BUNDLE, TRACE, FACTS + [("new-fact", e)])
    print(f"  +{e:10s} facts 25  visible {y['visible']:2d}  returned {y['returned']}"
          f"  open surface {y['open_surface']:2d}")
```

```
facts: 24 | axes: 9
strict bundle: mechanisms 8 | visible 10 | closed 14

fact shown by channel       axis       status under strict bundle
  root-filesystem-tree      mount      already visible
  other-mounts              mount      RETURNS
  file-ownership            user       already visible
  capability-set            privilege  RETURNS
  clock                     kernel     already visible

bundle          visible  returned  open surface  open axes
  plain             24           0          24           9
  common            12           2          14           7
  strict            10           2          12           7

cost: baseline 1000 -> run 1340 | percent 34
cost per returned fact: 17.0 percentage points
channel costs (model percentages , not a measurement): {'system-call-tracing': 34, 'sampling-profile': 3, 'in-kernel-tracing': 6, 'core-dump': 0}

FACT SET SWEEP -- removal (strict bundle + tracing channel)
  baseline open surface 12 | changes it 12 | does not 12
    lowers returned: other-mounts, capability-set -> returned 1 , open surface 11
    lowers visible : 10 facts -> visible 9 , open surface 11

FACT SET SWEEP -- addition (one new fact per axis)
  +host       facts 25  visible 10  returned 2  open surface 12
  +kernel     facts 25  visible 11  returned 2  open surface 13
  +label      facts 25  visible 10  returned 2  open surface 12
  +mount      facts 25  visible 10  returned 2  open surface 12
  +network    facts 25  visible 10  returned 2  open surface 12
  +privilege  facts 25  visible 10  returned 2  open surface 12
  +process    facts 25  visible 10  returned 2  open surface 12
  +resource   facts 25  visible 10  returned 2  open surface 12
  +user       facts 25  visible 10  returned 2  open surface 12
```

#### Oracle, Channel, Returning Fact

Three numbers sit side by side. **Oracle:** the process's 24 facts, over nine axes. **Channel:**
system call tracing shows five facts. **Returning fact and open surface:** **2** of these five
facts are ones the strict bundle closed, and they return; the open surface climbs from **10 to
12**.

The returning pair is `other-mounts` and `capability-set`. The first is a fact the mount
namespace closes: even if the root filesystem tree has been changed, which mount points the
process reads still show up in the call sequence. The second is a fact capability dropping
closes: which capability the process is trying to use is readable even from a call's failed
return. Both are facts the mechanisms call "closed," and the channel reopens both.

The **axis** count, meanwhile, stays at 7. The reason is that the two returning facts' axes
already had something open: `root-filesystem-tree` on the mount axis, `file-permissions` on
the privilege axis were already visible under the strict bundle too. So this channel **opens no
new axis**, it **thickens** the open axes. The distinction matters: the axis is the subject of
an isolation decision, the fact is the measure of that decision's quality.

#### Five Facts Shown, Two New

**Three** of the five facts the channel shows — `root-filesystem-tree`, `file-ownership`,
`clock` — were **already visible** under the strict bundle. Tracing them opens nothing new; it
only repeats existing visibility.

The reading that follows is this topic's counterpart of the course's second claim: **a tool
showing a lot does not mean it brings back a lot.** System call tracing is the most detailed of
the four channels; it gives every call, its arguments, and its return value. Yet the number of
facts it brings back is **2**, and that is not the highest number among the four channels.
There is no direct proportion between the number of lines a tool produces and the gap it opens
in isolation.

The bundle table says the same thing from another angle. With no isolation, the channel brings
back **0** facts — there is no closed fact to bring back, all 24 are already visible. In the
common six-mechanism bundle, the return is **2**; in the strict eight-mechanism bundle, still
**2**. The two mechanisms added narrow this channel's gap **not at all**: the user namespace
leaves `file-ownership` open, and mandatory labeling closes a fact not on this channel's list.
Tightening isolation does not close the surface a channel opens on its own.

#### Cost: The Run Slows by 34 Percent

The channel's cost is that it slows the run. The model's baseline run is 1000 time units; with
tracing on it becomes **1340**, that is, **34 percent**. This number and the other three
channels' numbers are **percentages in the model, not a real measurement**; the real value
would come out differently depending on the machine, the load, and the kind of call traced.
The reason they stand as they do in the model is to keep the order of magnitude fixed.

The cost's source is structural. Tracing hands control to the observing side on every system
call the observed process makes; one call turns into several context switches. So the cost
**grows with call density**: a compute-heavy run is affected little, a file- and network-heavy
run a lot. The model takes the cost as fixed (**OD6**), and on a real system this assumption is
a rough approximation.

Cost per returning fact is **17.0 percentage points**. This ratio will be the comparison unit
for the rest of the topic; for now, on its own, it means tracing is an **expensive and narrow**
channel. The cost's second face falls outside the measurement: a slowed process changes its
time-sensitive behavior. Timeouts fill at different points, race conditions land in a different
order. Tracing does not just observe, it **changes** what it observes; an intermittent fault
can disappear while tracing is on.

#### Fact Set Sweep

There is no second seed in this course; instead the fact set is swept. In the removal sweep, 24
facts are dropped one at a time from the list. The result: the open surface changes with **12**
facts, does not change with **12**. Of those that change it, only **2** — `other-mounts` and
`capability-set` — lower the returned count; the remaining 10 are already-visible facts, and
removing them lowers the visible count.

The addition sweep says something sharper. When one new fact is added to each of the nine axes,
the open surface grows on **only one axis**: `kernel`. The reason sits directly in the
mechanism table — none of the eight mechanisms closes the kernel axis. Kernel version, kernel
settings, system load, and the clock stay visible as is, even under the strict bundle. In the
model, every new fact added to this axis is written straight to the open surface; facts added
to the other eight axes stay closed.

This result also shows where the measurement depends on the mock setup. The open surface's
**size** depends on the chosen 24 facts, and a different list would give a different number.
The open surface's **sensitivity**, though, depends not on the list but on the mechanism table:
on whichever axis has no closer, the surface grows on that axis.

#### Narrowing the Surface

This lesson counts a gap, not how to use it. The course's boundary is binding: no escape,
privilege escalation, or evasion procedure is written in any lesson. Every counted surface is
written up together with its narrowing path, and this channel has four.

The first is the tracing capability itself. Tracing a process requires a separate
capability. Among processes under the same user ID, what actually cuts tracing off is the
kernel's `ptrace_scope` setting; adding that capability to the capability-dropping list
cuts off tracing across different identities. The second is the tracer's **position**: if
the observing side sits outside the bundle (**OD1**), the counted gap is open; if the
tracer is brought inside the same bundle, the channel becomes bound by the bundle's
restrictions too. The third is the tracing output itself; if the dump is written to a
file, that file carries every path and privilege attempt the process saw, and its access
has to be restricted. The fourth is scope: tracing is turned on for the duration of the
diagnosis and with a narrow call filter, not left on continuously.

All four say one thing in common. The channel is not a flaw in isolation; it is **a separate
access-control question.** When the isolation decision and the observation decision are not
made in the same place, the second decision silently undoes the first. The principle of least
privilege was itself established in the Cybersecurity curriculum and is not repeated here; its
counterpart here is one sentence: whoever can trace a bundle is whoever can see some of the
facts that bundle closes, and that visibility is written not in the bundle's configuration but
in the tracer's privilege.

#### Summary

- The strict bundle closes 14 of 24 facts and leaves 10 visible; system call tracing brings
  back **2** of the closed ones and the open surface becomes **12**.
- **Three** of the five facts the channel shows were already visible: a tool showing a lot is
  not a tool that brings back a lot.
- The returning pair is `other-mounts` and `capability-set`; open axes stay at 7, meaning the
  channel opens no new axis, it thickens open ones.
- The cost is **34 percent** (baseline 1000, run 1340) and **17.0 percentage points** per
  returning fact; these percentages are model values, not a real measurement.
- Sweep: the open surface changes with 12 of 24 facts; a newly added fact grows the surface
  only on the **kernel** axis, because no mechanism closes that axis.
- Narrowing: dropping the tracing capability, bringing the tracer inside the bundle, access
  control on the dump file, and a narrow scope.

#### Next Step

This channel was expensive and narrow. The next channel sits at the opposite end: a
sampling-based CPU profile slows the run by only **3 percent**, that is, it costs less than a
tenth of tracing. The next lesson counts how many facts this cheap channel brings back — the
answer is **1**, and it is a fact the control group closes. The table will show that cheapness
alone is not a virtue, and that growing the sample count never changes the returning fact at
all.

### Performance Profiling

Source: https://academia.sh/en/courses/kernel-interfaces/performance-profiling

The cheapest of the channels that cost anything during the run and the narrowest of the four: the sampling-based CPU profile brings back only 1 of the 14 facts the strict bundle closes, the open surface becomes 11, and the run slows by 3 percent; the returned fact does not change when the sample count grows a thousandfold.

The previous lesson measured an expensive channel: system call tracing brought back 2 of
the 14 facts the strict bundle closes, and slowed the run by 34 percent. Cost per
returning fact was 17.0 percentage points. This lesson sits at the other end of the scale.
The sampling-based CPU profile is the **cheapest** of the channels that cost anything
during the run: the slowdown it adds to the run is 3 percent.

The question is what that cheapness costs. If a channel gives information without touching the
run, its power to pierce isolation might be low too; or, the other way around, a cheap channel
might be exactly the one that opens the real gap, because it can be left on continuously. The
measurement says which of the two is true: the profile is also the narrowest in the number of
facts it brings back — the returning fact is **1**.

#### The Profile as a Channel

A sampling profile stops the running process at regular intervals and records which code is
executing at that moment. What it collects is not a sequence of events but a **distribution**:
what share of the samples each function collected. How to read a profile — which metric to pick,
which event the sampling rate will miss — was measured on the frontend side in Frontend Quality
and on the operations side in the Observability and Operations course, and is not repeated here.
In this lesson the profile is measured not as a performance tool but **as an isolation
channel**.

The assumptions continue and widen. The three facts the profile channel shows are the list in
the code (**OD8**). Costs are percentages in the model, **not a real measurement**; the baseline
run is 1000 time units (**OD9**). The profile is on for the whole run, and its cost is taken as
independent of sample count (**OD10**) — on a real system the cost rises as sample rate rises,
and the model closes off this distinction. The sample count is a mock value and every sample
produces one line (**OD11**). Sample count improves a fact's **estimate**, it does not change
the fact's visibility (**OD12**). The profiling tool runs from outside the bundle, during the
run (**OD13**).

```text
# example dump , has not been run
$ perf record -F <sample-rate> -g -p <process-no> -- sleep <seconds>
[ perf record: <sample-count> samples written ]
$ perf report --stdio --sort symbol
# Overhead  Symbol
#  <percent>  [.] <function-a>
#  <percent>  [.] <function-b>
#  <percent>  [k] <kernel-function>
```

The values in the dump are mock and the block has not been run. What is measured is not the
dump's lines but the **facts** readable from it.

```python
# M03/K05 common definition -- only the parts this lesson uses.
FACTS = [
    ("other-process-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-directory", "mount"),
    ("host-interfaces", "network"), ("host-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("hostname", "host"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("memory-used", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("security-label", "label"), ("policy-rules", "label"),
]
MECHANISM = {
    "pid-namespace":     {"axis": ["process"], "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": ["mount"], "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": ["network"], "left_open": []},
    "user-namespace":    {"axis": ["user"], "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": ["host"], "left_open": []},
    "control-group":     {"axis": ["resource"], "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": ["privilege"], "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": ["label"], "left_open": ["security-label"]},
}
COMMON_BUNDLE = ["pid-namespace", "mount-namespace", "network-namespace",
                 "uts-namespace", "control-group", "capability-dropping"]
STRICT_BUNDLE = COMMON_BUNDLE + ["user-namespace", "mandatory-label"]

CHANNEL = {  # OD8: facts the profile channel shows ; tracing channel for comparison
    "sampling-profile":     ["cpu-share", "cpu-count", "system-load"],
    "system-call-tracing": ["root-filesystem-tree", "other-mounts", "file-ownership",
                             "capability-set", "clock"],
}
# OD9: costs are percentages in the MODEL, not a real measurement
CHANNEL_COST = {"sampling-profile": 3, "system-call-tracing": 34}
BASELINE_TIME = 1000
# OD11-OD13: sample count is a construct; each sample produces one line
SAMPLE_COUNT = [100, 1000, 10000, 100000]


def visible(mechanisms, facts=FACTS):
    closed = set()
    for d in mechanisms:
        v = MECHANISM[d]
        for e in v["axis"]:
            closed |= {o for o, x in facts if x == e}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def returned(mechanisms, channel, facts=FACTS):
    closed = {o for o, _ in facts} - set(visible(mechanisms, facts))
    return sorted(set(channel) & closed)


def surface(mechanisms, channel=(), facts=FACTS):
    g = set(visible(mechanisms, facts))
    back = set(returned(mechanisms, channel, facts))
    return {"visible": len(g), "returned": len(back), "open_surface": len(g | back),
            "open_axes": len({e for o, e in facts if o in (g | back)})}


PROFILE = CHANNEL["sampling-profile"]
g = set(visible(STRICT_BUNDLE))
ys = surface(STRICT_BUNDLE, PROFILE)
print("facts:", len(FACTS), "| axes:", len({e for _, e in FACTS}),
      "| strict bundle: visible", len(g), "closed", len(FACTS) - len(g))
print()
print("RESOURCE AXIS (the control group's axis)")
for o, e in FACTS:
    if e == "resource":
        status = ("left open" if o in MECHANISM["control-group"]["left_open"]
                   else "RETURNS" if o in PROFILE else "closed")
        print(f"  {o:20s}{status}")
print()
print("fact shown by channel   axis      status under strict bundle")
for o in PROFILE:
    print(f"  {o:24s}{dict(FACTS)[o]:10s}"
          f"{'already visible' if o in g else 'RETURNS'}")
print()
print("bundle          visible  returned  open surface  open axes")
for ad, d in (("plain", []), ("common", COMMON_BUNDLE), ("strict", STRICT_BUNDLE)):
    y = surface(d, PROFILE)
    print(f"  {ad:12s} {y['visible']:7d}  {y['returned']:10d}"
          f"  {y['open_surface']:10d}  {y['open_axes']:10d}")
print()
print("channel                 returned  open surface  cost  run  cost per fact")
for k in CHANNEL:
    y = surface(STRICT_BUNDLE, CHANNEL[k])
    b = CHANNEL_COST[k]
    print(f"  {k:22s} {y['returned']:4d}  {y['open_surface']:10d}  {b:5d}"
          f"  {BASELINE_TIME * (100 + b) // 100:5d}  {b / y['returned']:17.2f}")
print()
print("SAMPLE COUNT SWEEP (returned fact does not depend on sample count)")
print("  samples   lines  returned fact  lines per fact")
for n in SAMPLE_COUNT:
    print(f"  {n:6d}  {n:8d}  {ys['returned']:15d}  {n // ys['returned']:17d}")
print()
print("FACT SET SWEEP -- removal")
returned_falls = [o for o, _ in FACTS
                   if surface(STRICT_BUNDLE, PROFILE,
                              [x for x in FACTS if x[0] != o])["returned"] < ys["returned"]]
changed = [o for o, _ in FACTS
           if surface(STRICT_BUNDLE, PROFILE,
                      [x for x in FACTS if x[0] != o])["open_surface"] != ys["open_surface"]]
print("  baseline open surface", ys["open_surface"], "| changes it", len(changed),
      "| does not", len(FACTS) - len(changed))
print("  the only fact zeroing out the return:", returned_falls)
print()
print("FACT SET SWEEP -- addition (one new fact per axis)")
for e in sorted({e for _, e in FACTS}):
    y = surface(STRICT_BUNDLE, PROFILE, FACTS + [("new-fact", e)])
    print(f"  +{e:10s} facts 25  open surface {y['open_surface']:2d}", end="")
    print("   <-- grows" if y["open_surface"] > ys["open_surface"] else "")
```

```
facts: 24 | axes: 9 | strict bundle: visible 10 closed 14

RESOURCE AXIS (the control group's axis)
  cpu-share           RETURNS
  memory-limit        closed
  memory-used         closed
  cpu-count           left open

fact shown by channel   axis      status under strict bundle
  cpu-share               resource  RETURNS
  cpu-count               resource  already visible
  system-load             kernel    already visible

bundle          visible  returned  open surface  open axes
  plain             24           0          24           9
  common            12           1          13           7
  strict            10           1          11           7

channel                 returned  open surface  cost  run  cost per fact
  sampling-profile          1          11      3   1030               3.00
  system-call-tracing       2          12     34   1340              17.00

SAMPLE COUNT SWEEP (returned fact does not depend on sample count)
  samples   lines  returned fact  lines per fact
     100       100                1                100
    1000      1000                1               1000
   10000     10000                1              10000
  100000    100000                1             100000

FACT SET SWEEP -- removal
  baseline open surface 11 | changes it 11 | does not 13
  the only fact zeroing out the return: ['cpu-share']

FACT SET SWEEP -- addition (one new fact per axis)
  +host       facts 25  open surface 11
  +kernel     facts 25  open surface 12   <-- grows
  +label      facts 25  open surface 11
  +mount      facts 25  open surface 11
  +network    facts 25  open surface 11
  +privilege  facts 25  open surface 11
  +process    facts 25  open surface 11
  +resource   facts 25  open surface 11
  +user       facts 25  open surface 11
```

#### Oracle, Channel, Returning Fact

Three numbers sit side by side. **Oracle:** the process's 24 facts, over nine axes.
**Channel:** the sampling profile shows three facts. **Returning fact and open surface:**
**1** of the three is a fact the strict bundle closed and it returns; the open surface climbs
from **10 to 11**, open axes stays at **7**.

The single returning fact is `cpu-share`. The other two facts — `cpu-count` and `system-load`
— were already visible under the strict bundle. This is the smallest open surface among the
four channels: **11**. The same number is 12 for system call tracing, 13 for the remaining
two channels.

Why the two already-visible facts are visible reads separately too. `cpu-count` is a fact the
control group deliberately leaves open: a process keeps seeing how many CPUs exist, only its
share of them is limited. `system-load` sits on the kernel axis, and no mechanism closes that
axis; the previous lesson's addition sweep already showed this in one line. So two of the
profile's three facts sit exactly where isolation never touches; the channel opens no gap
there, it only makes existing visibility readable.

The bundle table gives one more detail. In the common six-mechanism bundle too, the return is
**1**; tightening isolation does not narrow this channel's gap. The reason shows up in the
table: the mechanism that opens the gap is the **control group**, and it is present in the
common bundle too. What the channel pierces is not the two mechanisms added later to the
bundle, it is a mechanism at the bundle's **core**.

#### The Control Group's Axis Partly Reopens

The resource axis has four facts. The control group closes three of them and leaves
`cpu-count` open; this was measured in the control groups lesson and is not repeated here. The
profile brings back one of the three closed ones — `cpu-share`. Result: **two** of the resource
axis's four facts become visible. The control group alone left 25 percent visibility; with the
profile on, this climbs to 50 percent.

The reason is structural and goes beyond the model. The control group limits **how much
resource** a process can take; the profile counts **how much resource** the process uses. The
two are two faces of the same quantity. Limiting requires measuring, and measuring makes
visible. A control group's own accounting counters hold this same quantity; the profile does
not produce this information from scratch, it makes information already kept readable with
per-process separation.

The remaining two facts — `memory-limit` and `memory-used` — stay closed on this channel. The
profile is CPU-centered; the memory side will be opened by another channel, and that channel is
measured in this topic's last lesson.

Among the four channels, the profile is the only one whose returning fact is a quantity **kept
in a mechanism's own accounting**. The others read a fact from a place the mechanism never
records at all. This difference has a counterpart on the narrowing side: closing visibility
without stopping measurement is hard here, because measurement is part of isolation's own
operation. The narrowing question has to be asked not as "should this be measured" but as
**"who should be able to read it."**

#### More Samples, Same Fact

The sample-count sweep pays the course's second claim on this channel. When the sample count
grows from 100 to 100000, that is, **a thousandfold**, the line count grows a thousandfold too;
the returning fact stays fixed at **1**. Lines per fact climb from 100 to 100000. This
relationship is defined in the model (**OD12**), but the definition is not arbitrary: a fact's
visibility is a binary property, while sample count determines the precision of that fact's
**estimate**.

The distinction holds on the operational side too. An operator raising the sample rate gets a
finer distribution — a more precise answer to whether a function's share is 40 percent or 42
percent. Raising it does not give a **new fact**: which processes are sharing the CPU is
equally clear at the first sample and the hundred-thousandth. For someone looking at this from
an isolation angle, this channel's risk is independent of sample rate.

The cost side reads from the same place. The profile's cost is **3 percent**: the baseline run
of 1000 time units becomes 1030 with the profile on. Cost per returning fact is **3.00
percentage points**; against system call tracing's **17.00** points, the profile is **5.67
times** cheaper per fact. This cheapness has a consequence: an expensive channel is closed once
diagnosis ends, a cheap channel **can be left on continuously**. Continuity makes a single
fact's visibility permanent.

#### Fact Set Sweep

In the removal sweep, the open surface changes with **11** of 24 facts, does not change with
**13**. The **only** fact that lowers the returned count is `cpu-share`; when it is removed, the
channel brings back no fact and the open surface drops to 10. That is, this channel's entire
gap rests on a single fact. This shows the measurement's dependence on the mock setup honestly:
if the list changed, the profile's gap could close entirely.

The addition sweep confirms the previous lesson's result. When one fact is added to each of the
nine axes, the open surface grows only on the **`kernel`** axis (from 11 to 12). None of the
eight mechanisms closes that axis; a new fact added to the resource axis, in contrast, is
closed by the control group and is not written to the surface. If an axis has a closer, a fact
list growing on that axis does not grow the surface.

#### Narrowing the Surface

This channel's gap is narrow, but it has narrowing paths, and they need to be written up next
to the counted surface. The first is the sampling privilege: access to CPU counters is bound to
a separate capability and can be added to the capability-dropping list; sampling can be turned
off for unprivileged processes given kernel settings. The second is scope: the profile is
turned on to target a single process or a single control group, not the whole machine —
machine-wide profiling also makes bundle-external processes' CPU share visible. The third is
the profile file itself; the collected distribution carries function names and call stacks, and
the file is bound to access control. The fourth is duration: it is turned on for the diagnosis
and not left on continuously.

This lesson counts a gap, it does not describe its use. Escape, privilege escalation, and
evasion procedures are written in no lesson of this course; what is measured is which fact
stays visible.

#### Summary

- The sampling profile shows three facts; only **1** of them (`cpu-share`) returns under the
  strict bundle and the open surface becomes **11** — the smallest surface of the four
  channels.
- Open axes stay at **7**; the profile opens no new axis, it thickens the resource axis:
  visible facts on that axis climb from 1 to 2 of its four.
- The cost is **3 percent** (baseline 1000, run 1030) and **3.00 percentage points** per
  returning fact — **5.67 times** cheaper per fact than system call tracing's 17.00 points.
  The percentages are model values, not a real measurement.
- When sample count grows **a thousandfold**, line count grows a thousandfold too, and the
  returning fact stays at **1**: sample count improves the estimate, it does not grow the
  surface.
- Sweep: the channel's whole gap rests on one fact (`cpu-share`); a newly added fact again
  grows the surface only on the **kernel** axis.
- Narrowing: dropping the sampling capability, narrowing the profile to a single process or
  control group, access control on the profile file, and a bounded duration.

#### Next Step

Two channels have been measured, and both worked in the same direction: they left the open-axis
count at 7. The next channel breaks this. Event-based in-kernel tracing brings back **3** facts
for a 6-percent cost, and one of them reopens the axis the network namespace closes **fully**;
open axes climbs from 7 to **8**. The next lesson measures this channel by splitting it into
three tracepoints and writes up the result: the channel that brings back the most is not the
most expensive channel.

### In-Kernel Tracing

Source: https://academia.sh/en/courses/kernel-interfaces/in-kernel-tracing

Measuring event-based in-kernel tracing: 3 facts return for a 6-percent cost, the open surface becomes 13, and the axis the network namespace closes fully reopens, taking open axes from 7 to 8; at 2.00 points per fact, it is the lowest of the channels that cost anything during the run.

The previous two channels worked in the same direction. System call tracing brought back 2
facts for a 34-percent cost, the sampling profile 1 fact for 3 percent; both left the open-axis
count at **7**. The open-axis count staying fixed was not a small detail — the channels
thickened open axes, they did not open a closed one.

This lesson breaks that pattern. Event-based in-kernel tracing brings back **3** of the 14
facts the strict bundle closes, and one of them belongs to an axis the network namespace closes
**fully**. Open axes climb from 7 to **8**. The cost is 6 percent — less than a fifth of system
call tracing. This topic's sharpest sentence follows from here: **the channel that brings back
the most is not the most expensive channel.**

#### Event-Based Observation

System call tracing hands control to the observing side on every call; the sampling profile
takes a snapshot at regular intervals. In-kernel tracing is a third way: **tracepoints** are
placed inside the kernel, a small handler runs when an event occurs, and the result is written
to a buffer. The process is not stopped, no sample is waited for; only the event of interest is
recorded.

Assumptions continue. The five facts the channel shows are the list in the code (**OD14**).
Costs are **percentages in the model, not a real measurement**; the baseline run is 1000 time
units (**OD15**). Tracing is set up on the kernel side and runs independently of the traced
process's namespaces (**OD16**); this is the source of the channel's power to pierce isolation.
When an event is recorded, the related fact becomes fully visible (**OD17**). The channel splits
into three tracepoints, and the points' fact sets are the lists in the code (**OD18**). Point
costs are 2, 3, and 1, and they sum to the channel cost (**OD19**). Points can be turned on and
off independently of one another (**OD20**). Buffer overflow and event drops are not in the
model (**OD21**) — on a real system, high event rates thin out recording, and this boundary
falls outside the measurement.

```text
# example dump , has not been run
$ cat /sys/kernel/tracing/available_events | wc -l
<event-count>
$ echo 1 > /sys/kernel/tracing/events/sched/sched_process_fork/enable
$ cat /sys/kernel/tracing/trace_pipe
  <process-name>-<no> [<cpu>] <time>: sched_process_fork: parent=<no> child=<no>
```

The block has not been run and the values in it are mock. What is measured is not the lines'
format, it is the facts readable from the lines.

```python
# M03/K05 common definition -- only the parts this lesson uses.
FACTS = [
    ("other-process-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-directory", "mount"),
    ("host-interfaces", "network"), ("host-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("hostname", "host"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("memory-used", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("security-label", "label"), ("policy-rules", "label"),
]
MECHANISM = {
    "pid-namespace":     {"axis": ["process"], "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": ["mount"], "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": ["network"], "left_open": []},
    "user-namespace":    {"axis": ["user"], "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": ["host"], "left_open": []},
    "control-group":     {"axis": ["resource"], "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": ["privilege"], "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": ["label"], "left_open": ["security-label"]},
}
STRICT_BUNDLE = ["pid-namespace", "mount-namespace", "network-namespace", "uts-namespace",
                 "control-group", "capability-dropping", "user-namespace", "mandatory-label"]

CHANNEL = {
    "system-call-tracing": ["root-filesystem-tree", "other-mounts", "file-ownership",
                             "capability-set", "clock"],
    "sampling-profile":     ["cpu-share", "cpu-count", "system-load"],
    "in-kernel-tracing":   ["other-process-list", "ancestor-process-chain",
                             "listening-ports", "kernel-settings", "system-load"],
    "core-dump":           ["memory-used", "capability-set", "security-label",
                             "user-id-mapping"],
}
# OD15: costs are MODEL percentages , not a real measurement
CHANNEL_COST = {"system-call-tracing": 34, "sampling-profile": 3,
                "in-kernel-tracing": 6, "core-dump": 0}
BASELINE_TIME = 1000
# OD18-OD20: the channel splits into three tracepoints ; point costs 2+3+1 = 6
POINT = {
    "process-events":       (["other-process-list", "ancestor-process-chain"], 2),
    "network-events":       (["listening-ports"], 3),
    "kernel-setting-events": (["kernel-settings", "system-load"], 1),
}


def visible(mechanisms, facts=FACTS):
    closed = set()
    for d in mechanisms:
        v = MECHANISM[d]
        for e in v["axis"]:
            closed |= {o for o, x in facts if x == e}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def returned(mechanisms, channel, facts=FACTS):
    closed = {o for o, _ in facts} - set(visible(mechanisms, facts))
    return sorted(set(channel) & closed)


def surface(mechanisms, channel=(), facts=FACTS):
    g = set(visible(mechanisms, facts))
    back = set(returned(mechanisms, channel, facts))
    return {"visible": len(g), "returned": len(back), "open_surface": len(g | back),
            "open_axes": len({e for o, e in facts if o in (g | back)})}


IN_KERNEL = CHANNEL["in-kernel-tracing"]
g = set(visible(STRICT_BUNDLE))
ys = surface(STRICT_BUNDLE, IN_KERNEL)
print("facts:", len(FACTS), "| axes:", len({e for _, e in FACTS}),
      "| strict bundle: visible", len(g), "closed", len(FACTS) - len(g),
      "open axes", surface(STRICT_BUNDLE)["open_axes"])
print()
print("fact shown by channel         axis      status under strict bundle")
for o in IN_KERNEL:
    print(f"  {o:28s}{dict(FACTS)[o]:10s}"
          f"{'already visible' if o in g else 'RETURNS'}")
print()
print("AXIS TABLE (fact count | visible under strict | visible with channel on)")
back = set(returned(STRICT_BUNDLE, IN_KERNEL))
for e in sorted({e for _, e in FACTS}):
    all_ = [o for o, x in FACTS if x == e]
    a = len([o for o in all_ if o in g])
    b = len([o for o in all_ if o in g | back])
    note = "  <-- axis reopened" if a == 0 < b else ""
    print(f"  {e:10s} {len(all_):5d} {a:24d} {b:26d}{note}")
print()
print("channel                 returned  open surface  open axes  cost  cost per fact")
for k in CHANNEL:
    y = surface(STRICT_BUNDLE, CHANNEL[k])
    b = CHANNEL_COST[k]
    ratio = f"{b / y['returned']:.2f}" if y["returned"] else "-"
    print(f"  {k:22s} {y['returned']:4d}  {y['open_surface']:10d}  {y['open_axes']:10d}"
          f"  {b:5d}  {ratio:>17s}")
print()
print("TRACEPOINT SWEEP (points open one after another)")
print("  point                   new fact  total returned  total cost  marginal cost")
open_, total = [], 0
for ad, (facts_, cost) in POINT.items():
    before = len(returned(STRICT_BUNDLE, open_))
    open_ = open_ + facts_
    now = len(returned(STRICT_BUNDLE, open_))
    total += cost
    print(f"  {ad:22s} {now - before:10d} {now:12d} {total:13d} {cost:15d}")
print("  total tracepoint cost:", total, "= channel cost", CHANNEL_COST["in-kernel-tracing"],
      "| run", BASELINE_TIME * (100 + total) // 100)
print()
print("FACT SET SWEEP -- removal")
lowers_return = [o for o, _ in FACTS
                 if surface(STRICT_BUNDLE, IN_KERNEL,
                            [x for x in FACTS if x[0] != o])["returned"] < ys["returned"]]
changes = [o for o, _ in FACTS
           if surface(STRICT_BUNDLE, IN_KERNEL,
                      [x for x in FACTS if x[0] != o])["open_surface"] != ys["open_surface"]]
lowers_axes = [o for o, _ in FACTS
               if surface(STRICT_BUNDLE, IN_KERNEL,
                          [x for x in FACTS if x[0] != o])["open_axes"] < ys["open_axes"]]
print("  baseline open surface", ys["open_surface"], "open axes", ys["open_axes"],
      "| changes it", len(changes), "| does not", len(FACTS) - len(changes))
print("  lowers returned:", ", ".join(lowers_return))
print("  fact that lowers open axes from 8 to 7:", len(lowers_axes),
      "| of these , the one the channel brings back:",
      [o for o in lowers_axes if o in returned(STRICT_BUNDLE, IN_KERNEL)])
print()
grows = [e for e in sorted({e for _, e in FACTS})
         if surface(STRICT_BUNDLE, IN_KERNEL,
                    FACTS + [("new-fact", e)])["open_surface"] > ys["open_surface"]]
print("FACT SET SWEEP -- addition (one new fact per axis)")
print("  axis growing the surface:", grows, "-> open surface", ys["open_surface"] + 1)
print("  the other", 9 - len(grows), "axes stay at open surface", ys["open_surface"])
```

```
facts: 24 | axes: 9 | strict bundle: visible 10 closed 14 open axes 7

fact shown by channel         axis      status under strict bundle
  other-process-list          process   RETURNS
  ancestor-process-chain      process   RETURNS
  listening-ports             network   RETURNS
  kernel-settings             kernel    already visible
  system-load                 kernel    already visible

AXIS TABLE (fact count | visible under strict | visible with channel on)
  host           1                        0                          0
  kernel         4                        4                          4
  label          2                        1                          1
  mount          3                        1                          1
  network        3                        0                          1  <-- axis reopened
  privilege      2                        1                          1
  process        3                        1                          3
  resource       4                        1                          1
  user           2                        1                          1

channel                 returned  open surface  open axes  cost  cost per fact
  system-call-tracing       2          12           7     34              17.00
  sampling-profile          1          11           7      3               3.00
  in-kernel-tracing         3          13           8      6               2.00
  core-dump                 3          13           7      0               0.00

TRACEPOINT SWEEP (points open one after another)
  point                   new fact  total returned  total cost  marginal cost
  process-events                  2            2             2               2
  network-events                  1            3             5               3
  kernel-setting-events           0            3             6               1
  total tracepoint cost: 6 = channel cost 6 | run 1060

FACT SET SWEEP -- removal
  baseline open surface 13 open axes 8 | changes it 13 | does not 11
  lowers returned: other-process-list, ancestor-process-chain, listening-ports
  fact that lowers open axes from 8 to 7: 6 | of these , the one the channel brings back: ['listening-ports']

FACT SET SWEEP -- addition (one new fact per axis)
  axis growing the surface: ['kernel'] -> open surface 14
  the other 8 axes stay at open surface 13
```

#### Oracle, Channel, Returning Fact

Three numbers sit side by side. **Oracle:** the process's 24 facts, over nine axes.
**Channel:** in-kernel tracing shows five facts. **Returning fact and open surface:** **3** of
the five are facts the strict bundle closed and they return; the open surface climbs from
**10 to 13**, open axes from **7 to 8**.

The returning trio is `other-process-list`, `ancestor-process-chain`, and `listening-ports`.
The first two are facts the pid namespace closes: even if a process sees only itself inside its
own namespace, a process-event tracker placed on the kernel side sees fork events regardless of
namespace boundaries. The third is a fact the network namespace closes, and that is exactly
where the real break happens.

The two already-visible facts — `kernel-settings` and `system-load` — sit on the kernel axis.
The same result came up in the previous two lessons: no mechanism closes that axis. So two of
the channel's five facts open nothing new for this reason.

#### A Closed Axis Reopens

The axis table changes in this channel alone, among the four. The network axis has three
facts and the network namespace closes all three; in the common definition's first reading,
network and uts namespaces were the only pair that closes their own axes **fully**. In-kernel
tracing pierces that full closure: `listening-ports` returns, and the number of visible facts
on the network axis climbs from 0 to **1**.

The distinction matters operationally. An axis thickening is one more thing becoming visible
while something on that axis was already visible; it grows the measure but does not change the
isolation decision. An axis being **reopened**, though, invalidates the isolation decision
itself. The decision made when setting up the network namespace was "this process will not see
the machine's network"; with the channel on, that decision no longer holds. That the strict
bundle leaves something open on seven of nine axes was a known result; the eighth axis opening
**because of observation** is a separate result.

There is also a thickening on the process axis: the visible count out of three facts climbs
from 1 to **3**, that is, the axis becomes fully visible. Because the axis was already counted
open, no increase shows in the table; on the fact side, though, this is the channel's biggest
contribution.

#### The Channel That Brings Back the Most Is Not the Most Expensive

The four-channel comparison table gives this topic's main claim in a single glance. System
call tracing brings **2** facts for **34 percent**, in-kernel tracing brings **3** facts for
**6 percent**. Cost per fact is **17.00** for the first, **2.00** percentage points for the
second: in-kernel tracing is, per fact, **one eighth and a half** as expensive as system call
tracing, and brings back more facts. There is no link between expense and piercing power in
this model.

The reason is structural. System call tracing's cost is paid **on every call**; it grows with
the measured process's call density. In-kernel tracing's cost is paid **only on the chosen
events**; however much the process runs, nothing is paid for events not chosen. The first is
wide and blind, the second is narrow and selective. Selectivity is the source of both the
cheapness and the piercing power: when the right event is chosen, a single tracepoint gives
directly the fact a namespace closes.

The table's fourth row is already visible too: core dump brings **3** facts for **0 percent**
cost. Its cost is not in the run, it sits elsewhere, and it is measured in this topic's last
lesson.

#### Tracepoint Sweep

Once the channel is split into three points, marginal return shows plainly. The process-events
point brings **2** new facts and costs **2** points. The network-events point brings **1** new
fact and costs **3** points — the most expensive point is the point that brings back the
fewest facts; but the fact it brings is exactly the one that reopens the axis. The
kernel-setting-events point brings **0** new facts and still charges **1** point. The three
points sum to 6, equal to the channel cost; the run climbs from 1000 to 1060.

The third point is the most instructive one operationally. It is on, it produces records, it
charges a cost, and it adds **nothing** on the isolation side; the two facts it shows were
already visible. The question to ask when auditing an observation setup should not be "what
does this point show," it should be "what does this point show that is **new**." A point with
zero marginal return only charges a cost.

#### Fact Set Sweep

In the removal sweep, the open surface changes with **13** of 24 facts, does not change with
**11**. The three facts lowering the returned count are exactly the channel's own three
returning facts. There are **6** facts that lower open axes from 8 to 7; only one of them —
`listening-ports` — is a fact the channel brings back. The remaining five are removed and
close their own axis entirely, because they were the only fact visible on that axis under the
strict bundle.

This second result shows the fragility of the axis measure. An axis being counted "open" can
rest on a single fact; if the list changes, the axis count changes. The fact count is more
robust. The addition sweep gives the same answer as the previous two lessons: among the nine
axes, only the `kernel` axis grows the open surface with a new fact (13 to 14); the other eight
stay at surface **13**.

#### Narrowing the Surface

A narrowing path is written up next to the counted surface. The first is the privilege to place
a tracepoint: in-kernel tracing requires a separate privilege and it can be added to the
capability-dropping list; tracing can be turned off for unprivileged processes given kernel
settings. The second is point selection — as the sweep showed, points with zero marginal return
are turned off; as the number of open points falls, both cost and surface shrink. The third is
event filters: a tracepoint can be narrowed to a single control group or a single namespace;
machine-wide tracing also makes bundle-external processes' events visible. The fourth is on the
recording side; the collected event stream carries process names, ancestor chains, and ports,
and its access has to be restricted.

This lesson counts a gap, it does not describe its use. Escape, privilege escalation, and
evasion procedures are written in no lesson of this course.

#### Summary

- In-kernel tracing shows five facts; **3** return and the open surface becomes **13** — one
  of the two biggest returners among the four channels.
- Open axes climb from **7 to 8**; the axis the network namespace closes **fully** reopens,
  and this is the only channel that does so.
- The cost is **6 percent** (baseline 1000, run 1060) and **2.00 percentage points** per fact
  — **one eighth and a half** of system call tracing's 17.00 points. The percentages are model
  values, not a real measurement.
- The three tracepoints' marginal returns are 2, 1, and **0** facts; their costs are 2, 3, and
  1 points. The most expensive point brings the fewest facts, the third point charges a cost
  while bringing no new fact.
- Sweep: only one of the 6 facts that lower the open axes is a fact the channel brings; a
  newly added fact again grows the surface only on the **kernel** axis.
- Narrowing: dropping the tracing capability, turning off points with zero marginal return,
  narrowing events to a single control group, and access control on the event stream.

#### Next Step

All three channels ran during the run, and all three added a cost to the run. The next channel
touches the run **not at all**: a core dump looks **after** a process has crashed, and its run
cost is **0 percent**. Yet the number of facts it brings back is **3**, and among what it
brings are user ID mapping and the capability set. Why a channel with zero cost has one of the
largest surfaces is explained by **what the dump carries** — and this is the last lesson of
both this course and the M03 curriculum.

### Core Dump Analysis

Source: https://academia.sh/en/courses/kernel-interfaces/core-dump-analysis

Measuring the channel whose run cost is zero: a core dump brings back 3 of the 14 facts the strict bundle closes, the open surface becomes 13, and all four channels together bring back 8 facts, taking the return ratio to 0.5714; the dump's cost sits not in the run but in the sensitive data it carries.

Three channels have been measured, and all three ran during the run: system call tracing at
34 percent, in-kernel tracing at 6 percent, the sampling profile at 3 percent. The fourth
channel sits outside this pattern. A core dump is written **after** a process has crashed; it
charges the running process nothing, its run cost is **0 percent**.

A channel with zero cost is expected to be harmless. The measurement says the opposite: the
dump is among the top two in the number of facts it brings back — **3** — and among what it
brings are user ID mapping and the capability set. This lesson resolves that contradiction —
zero cost is not paid in the run, it is paid in **retention** — and closes the topic.

#### The Channel That Looks After the Run Ends

When a process ends on a fatal signal, the kernel can write the process's memory image to a
file. This file is not an event stream; it is a single **snapshot**, carrying the process's
final state: memory contents, privilege state, identity fields, traces of open resources.

Assumptions continue. The dump is written when the process ends and charges nothing during the
run (**OD22**). The four facts the dump carries are the list in the code (**OD23**). Costs are
**percentages in the model, not a real measurement**; the baseline run is 1000 time units
(**OD24**). The dump is a single instant: for a fact that changes over the run, only its final
value is visible (**OD25**). The dump file is persistent; the facts it carries stay visible as
long as it can be read (**OD26**). All four channels can be on at once, and their run costs sum
(**OD27**). The dump's size and retention duration are not in the model (**OD28**), and this is
the real cost that falls outside the measurement.

```text
# example dump , has not been run
$ cat /proc/sys/kernel/core_pattern
<dump-path-template>
$ ulimit -c
<dump-size-limit>
$ ls -l <dump-directory>
-rw-------  1 <owner> <group>  <size>  <time>  core.<process-name>.<no>
```

The block has not been run and its values are mock. That the file's permission bits are narrow
is not a coincidence; the reason is measured below.

```python
# M03/K05 common definition -- this lesson builds all four channels.
FACTS = [
    ("other-process-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-directory", "mount"),
    ("host-interfaces", "network"), ("host-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("hostname", "host"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("memory-used", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("security-label", "label"), ("policy-rules", "label"),
]
MECHANISM = {
    "pid-namespace":     {"axis": ["process"], "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": ["mount"], "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": ["network"], "left_open": []},
    "user-namespace":    {"axis": ["user"], "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": ["host"], "left_open": []},
    "control-group":     {"axis": ["resource"], "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": ["privilege"], "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": ["label"], "left_open": ["security-label"]},
}
STRICT_BUNDLE = ["pid-namespace", "mount-namespace", "network-namespace", "uts-namespace",
                 "control-group", "capability-dropping", "user-namespace", "mandatory-label"]
CHANNEL = {
    "system-call-tracing": ["root-filesystem-tree", "other-mounts", "file-ownership",
                             "capability-set", "clock"],
    "sampling-profile":     ["cpu-share", "cpu-count", "system-load"],
    "in-kernel-tracing":   ["other-process-list", "ancestor-process-chain",
                             "listening-ports", "kernel-settings", "system-load"],
    "core-dump":           ["memory-used", "capability-set", "security-label",
                             "user-id-mapping"],
}
# OD24: costs are MODEL percentages , not a real measurement
CHANNEL_COST = {"system-call-tracing": 34, "sampling-profile": 3,
                "in-kernel-tracing": 6, "core-dump": 0}
BASELINE_TIME = 1000


def visible(mechanisms, facts=FACTS):
    closed = set()
    for d in mechanisms:
        v = MECHANISM[d]
        for e in v["axis"]:
            closed |= {o for o, x in facts if x == e}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def returned(mechanisms, channels, facts=FACTS):
    closed = {o for o, _ in facts} - set(visible(mechanisms, facts))
    back = set()
    for k in channels:
        back |= set(CHANNEL[k]) & closed
    return sorted(back)


def surface(mechanisms, channels=(), facts=FACTS):
    g = set(visible(mechanisms, facts))
    back = set(returned(mechanisms, channels, facts))
    return {"visible": len(g), "returned": len(back), "open_surface": len(g | back),
            "open_axes": len({e for o, e in facts if o in (g | back)})}


g = set(visible(STRICT_BUNDLE))
closed = len(FACTS) - len(g)
ys = surface(STRICT_BUNDLE, ["core-dump"])
print("facts:", len(FACTS), "| axes:", len({e for _, e in FACTS}),
      "| strict bundle: visible", len(g), "closed", closed)
print()
print("fact carried by dump        axis       status under strict bundle")
for o in CHANNEL["core-dump"]:
    print(f"  {o:26s}{dict(FACTS)[o]:11s}"
          f"{'already visible' if o in g else 'RETURNS'}")
print()
print("channel                 returned  open surface  open axes  cost  run")
for k in CHANNEL:
    y = surface(STRICT_BUNDLE, [k])
    b = CHANNEL_COST[k]
    print(f"  {k:22s} {y['returned']:4d}  {y['open_surface']:10d}  {y['open_axes']:10d}"
          f"  {b:5d}  {BASELINE_TIME * (100 + b) // 100:5d}")
h = surface(STRICT_BUNDLE, list(CHANNEL))
bh = sum(CHANNEL_COST.values())
print(f"  {'ALL FOUR CHANNELS':22s} {h['returned']:4d}  {h['open_surface']:10d}"
      f"  {h['open_axes']:10d}  {bh:5d}  {BASELINE_TIME * (100 + bh) // 100:5d}")
print()
print("facts the strict bundle closes:", closed)
print("returned by four channels     :", h["returned"])
print("return ratio                  :", round(h["returned"] / closed, 4))
print("brought back by no channel    :",
      sorted(set(o for o, _ in FACTS) - g - set(returned(STRICT_BUNDLE, list(CHANNEL)))))
print()
print("FACT SET SWEEP (all four channels)")
changes = [o for o, _ in FACTS
           if surface(STRICT_BUNDLE, list(CHANNEL),
                      [x for x in FACTS if x[0] != o])["open_surface"] != h["open_surface"]]
grows = [e for e in sorted({e for _, e in FACTS})
         if surface(STRICT_BUNDLE, list(CHANNEL),
                    FACTS + [("new-fact", e)])["open_surface"] > h["open_surface"]]
print("  removal: open surface", h["open_surface"], "| changes it", len(changes),
      "| does not", len(FACTS) - len(changes))
print("  addition: axis growing the surface", grows, "-> open surface", h["open_surface"] + 1)
```

```
facts: 24 | axes: 9 | strict bundle: visible 10 closed 14

fact carried by dump        axis       status under strict bundle
  memory-used               resource   RETURNS
  capability-set            privilege  RETURNS
  security-label            label      already visible
  user-id-mapping           user       RETURNS

channel                 returned  open surface  open axes  cost  run
  system-call-tracing       2          12           7     34   1340
  sampling-profile          1          11           7      3   1030
  in-kernel-tracing         3          13           8      6   1060
  core-dump                 3          13           7      0   1000
  ALL FOUR CHANNELS         8          18           8     43   1430

facts the strict bundle closes: 14
returned by four channels     : 8
return ratio                  : 0.5714
brought back by no channel    : ['host-interfaces', 'host-routing-table', 'hostname', 'memory-limit', 'policy-rules', 'shared-temp-directory']

FACT SET SWEEP (all four channels)
  removal: open surface 18 | changes it 18 | does not 6
  addition: axis growing the surface ['kernel'] -> open surface 19
```

#### Oracle, Channel, Returning Fact

Three numbers sit side by side. **Oracle:** the process's 24 facts, over nine axes.
**Channel:** the core dump carries four facts. **Returning fact and open surface:** **3** of
the four are facts the strict bundle closed and they return; the open surface climbs from
**10 to 13**, open axes stays at **7**.

The returning trio is `memory-used`, `capability-set`, and `user-id-mapping`. All three are
facts three separate mechanisms close: the control group, capability dropping, and the user
namespace, respectively. A single file invalidates three separate isolation decisions at once.
The fourth fact, `security-label`, was already visible under the strict bundle; the mandatory
labeling mechanism deliberately leaves it open on its own axis.

Open axes staying at 7 confirms the distinction seen in the previous lesson: the dump reopens
no closed axis, it thickens three open ones. Yet it takes the open surface to **13**, and this,
together with in-kernel tracing, is the largest single surface among the four channels. The
channel with the lowest cost is one of the two channels that grow the surface the most.

#### Where Zero Cost Is Paid

The run cost is zero because the dump does not touch the running process. The cost is
elsewhere, and it shows up in three forms.

The first is **persistence** (**OD26**). The other three channels give information for as long
as they are on; visibility ends once they are turned off. The dump is a **file**: once written,
it keeps showing the facts it carries until it is deleted. Tracing turned on for an hour
produces an hour of visibility; a dump written once produces visibility for as long as it is
retained.

The second is the file's **content**. The dump carries the process's memory image; whatever is
in memory at that moment is in the dump too. This is a set far beyond the four facts counted in
the measurement: processed data, session information, configuration values, and credentials can
all sit in memory. **A core dump carries sensitive data.** This course does **not** write a
procedure for extracting confidential data from a dump; what it measures is which fact the dump
makes visible, not how that visibility gets abused.

The third is **access**. The path the dump is produced to and the file's ownership determine
who can read it. A dump written to a shared directory can be readable by processes outside the
isolation bundle too.

The narrowing paths follow directly from this. Dump production can be **turned off**, or bound
to a size limit through a per-process resource limit; dump production can also be separately
blocked for privilege-escalating processes. The file is written with **narrow permissions** and
to a directory only the privileged process can read, not to shared temp directories. Dumps are
bound to a **retention policy**: they are deleted once diagnosis ends. Encryption at rest
protects the file against backups and copies too. Together, the four make the real cost of a
channel with zero run cost manageable.

#### The Four Channels' Total

When all four channels are turned on together, the result is the course's third claim. The
strict bundle closed **14** of 24 facts; the four channels together bring back **8** of them.
The return ratio is **0.5714**: **more than half of what isolation closes returns through
observation.** The open surface climbs from 10 to **18**, open axes from 7 to **8**. Total run
cost is **43 percent** and the run stretches to 1430; keeping all four on at once is a rare
setup, but this line marks the setup's limit.

The six facts that do not return are instructive too: `host-interfaces`, `host-routing-table`,
`hostname`, `memory-limit`, `shared-temp-directory`, and `policy-rules`. These are on no
channel's list and stay closed. Isolation keeps working exactly where observation channels do
not look; what gets pierced is not the whole of isolation, it is **the intersection the
channels cover**.

The fact set sweep runs for the last time. On the removal side, the open surface changes with
**18** of 24 facts, does not change with **6** — the ones that do not change are exactly the
six above. On the addition side, the surface again grows only on the **`kernel`** axis (18 to
19). The same result came up in all four lessons: an axis with no closer writes every fact
added to the list straight to the surface.

#### Summary

- The core dump carries four facts; **3** return (`memory-used`, `capability-set`,
  `user-id-mapping`) and the open surface becomes **13**.
- Run cost is **0 percent**; the cost sits in persistence, the file's content, and its access.
  The dump **carries sensitive data** and its retention and access must be restricted.
- A single file invalidates three separate mechanisms' decisions at once — the control group,
  capability dropping, the user namespace.
- All four channels together: returned **8**, open surface **18**, open axes **8**, total cost
  **43 percent**; return ratio **0.5714**.
- Six facts return through no channel; isolation keeps working where the channels do not look.
- Narrowing: turning off dump production or binding it to a size limit, a narrow-permission
  unshared directory, a retention policy, and encryption at rest.

#### Course Wrap-Up

The course ran on a single rule: **an isolation's count is not the surface it closes, it is
the surface it leaves open; an isolation whose still-visible facts go unwritten counts as
unmeasured.** The table below sets nine lessons side by side under the same measure. The
`gozlem` topic's four rows are the numbers measured in this topic; the `izolasyon` topic's five
rows are filled with those lessons' own numbers.

| Lesson | Oracle | Isolation or channel | Open surface / returned |
|---|---|---|---|
| Namespaces | 24 facts / 9 axes | five namespaces | **15** visible; network and uts namespace are the only pair that closes its own axis **fully** |
| Control Groups | 24 facts / 9 axes | + control group | **12** visible; 3 facts close, `cpu-count` **stays open** |
| Capabilities | 24 facts / 9 axes | + capability dropping | **11** visible; only one of the axis's two facts closes, **file permissions stay open** |
| Mandatory Access Control | 8 of 36 access tuples required | + mandatory labeling | **10** visible (strict bundle); with no policy, **28 excess permissions**, a rough policy **10**, a narrow policy **1**; in permissive mode, surface is **11** instead of 10 |
| Container Runtime | 24 facts / 9 axes | common 6 mechanisms / strict 8 mechanisms | **12** versus **10**; two mechanisms narrow the surface by only **2 facts**, **open axes stays at 7** |
| System Call Tracing | 24 facts / 9 axes | channel: shows 5 facts, cost 34 percent | returned 2, open surface 12, open axes 7 |
| Performance Profiling | 24 facts / 9 axes | channel: shows 3 facts, cost 3 percent | returned 1, open surface 11, open axes 7 |
| In-Kernel Tracing | 24 facts / 9 axes | channel: shows 5 facts, cost 6 percent | returned 3, open surface 13, open axes 8 |
| Core Dump Analysis | 24 facts / 9 axes | channel: carries 4 facts, cost 0 percent | returned 3, open surface 13, open axes 7 |

The course's two topics were opposites of one another, and the measurement wrote this down in
numbers: isolation closed 14 facts, observation brought back 8 of them.

This lesson is also the end of the M03 curriculum. Five courses followed a single axis: **the
gap between the system's real state and the evidence in the operator's hand.** Introduction to
Linux measured reading names and permissions, Shell Programming turning a command into a tool,
System Administration how many times the diagnosis drawn from output is wrong, Linux Network
Administration and Troubleshooting narrowing a fault layer by layer, and Kernel Interfaces and
Isolation the surface isolation leaves open.

Two debts were paid too. Namespaces and control groups had been referred to this course twice —
in the System Administration course's service manager lesson and in the Linux Network
Administration course's access and filtering section. This course built both and measured
both **together with the surface they leave open**: the pid namespace leaves its own process
number open, the control group leaves the CPU count open, and observation channels bring back
some of the facts both close.

Next comes the Computer Networks curriculum. In this course, the network passed through only as
an **axis**: three facts closed, one channel reopening them. The network itself — the path a
packet follows between two endpoints, the layers' jobs, addressing, and protocols — is built
there. The first course, How the Internet Works, begins by following the end-to-end journey of
a request typed into an address bar.

