Process confinement
Give a native tool exactly the files, descriptors, and syscalls its task needs, and see its receipt say so.
On this page
Whatever a native tool's code does, a dependency bug or attacker-shaped input included, it reaches only what its task was granted: named read paths, one result file, no network. That bound lets an agent host admit tools it did not write.
Chio compiles a cage profile from the publisher's verified registry authority and the operator's independent ceiling, then installs it with Landlock and seccomp on Linux. The default profile denies filesystem access and blocks the network; grants add specific resources, and a signed declaration stays within the operator's ceiling. The host supplies this isolation; the kernel records whether it held.
A link added after compilation reaches nothing
A hard link to a forbidden file appears inside the admitted directory after the cage is compiled. Its path sits under the grant, but its object was never retained, so Landlock refuses the open with EACCES.
A file grant names the objects retained at compile time
A pathname is a lookup instruction, and a file can be replaced between review and use. The cage compiler opens each granted resource before launch, checks the descriptor's identity, and applies restrictions from those retained objects, so the tool reads the file that was reviewed. A wrapper that checks a path and later reopens it by another route breaks that link.
- Input tree. A read-directory grant freezes a bounded set of descendants; files created afterward inherit nothing, including symlinks or replacement paths that point elsewhere.
- Output. Writable directory grants are rejected. The exact-new-file grant retains the parent and creates one file with private permissions, so the tool cannot rewrite configuration a trusted process reads later.
- Executable. The selected target is retained and verified, closing the gap between manifest review and execution.
- Broker and inherited descriptors. The tool receives the operation's prepared descriptor and exactly the declared set, so a parent's other open files and sockets stay out of reach.
Start a profile from the executable and its runtime dependencies, the selected input tree, and the exact result file. The publisher's signed permissions say what the tool requests and the operator ceiling says what this deployment allows; launch needs both. Implicit dependencies then appear as failed accesses to review against the task, since widening a profile until the program runs grants more than the task warrants.
The Linux exercise behind the late link
The exercise creates a readable file in the admitted directory and a forbidden file outside it, compiles a legitimate reader and an attack launch, then adds a hard link to the forbidden object inside the admitted directory before either target runs. A path-prefix check would accept the link's location. From the resource comparison:
let existing_control = compile_for_target(required_path("CHIO_CAGE_TEST_DIRECTORY_READ"));
let late_link_attack = compile_for_target(required_path("CHIO_CAGE_TEST_DIRECTORY_HARD_LINK"));
std::fs::hard_link(&forbidden_file, &late_forbidden_link).test_unwrap();
for compiled in [existing_control, late_link_attack] {
let record = launch(compiled, CageLaunchOptions::default())
.test_unwrap()
.wait()
.test_unwrap();
assert_eq!(
record.exit.as_ref().and_then(|exit| exit.exit_code),
Some(0)
);
}
std::fs::remove_dir_all(&allowed_directory).test_unwrap();
std::fs::remove_file(&forbidden_file).test_unwrap();test result: ok. 27 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 67.19s
The control exits 0 only after reading one byte from the original file. The attack exits 0 only when opening the late link returns Linux EACCES; its C probe makes the syscall directly, and the parent checks the result independently of any application message. Raw result -13 is permission denied, and any accessible descriptor exits 128.
static const char path[] = "/tmp/chio-cage-allowed-directory/late-forbidden-link";
long result = invoke(SYS_OPENAT, AT_FDCWD, (long)path, O_RDONLY, 0);
terminate(result == -13 ? 0 : 128);The comparison passed on a Linux 6.14 x86-64 run with the 26 other tests in the enforcement target, which also covered exact reads and writes, refused neighboring files and symlink escapes, closed socket paths, retained target identity, and descriptor isolation, all with disposable resources.
The network stays closed, and the syscall filter stands apart from the file grant
The standard native profile gives the tool no socket capability. A brokered profile receives the matching preconnected broker descriptor and uses that one channel without gaining the right to open others.
The seccomp allowlist is independent of the filesystem policy: running a new executable, creating a socket, or inspecting another process each needs its own allowance, whatever files the tool may read. The initial exec is bound to the retained target descriptor, and the target cannot recreate that bootstrap opening after the transition. Syscalls that reach another process are refused outright: prlimit64 applies only to the tool itself, and kill, tkill, tgkill, and pidfd_send_signal are never allowed.
Read more in Resource limits, which covers these syscalls and the separate ceilings of chio process run workers.
A receipt says confined only with a fully enforced launch bound at dispatch
An enforced launch retains the applied Landlock and seccomp status, the execution identity, the target binding, and the kernel-observed exec event. At dispatch the kernel binds that record into the call's receipt, as native_launch for the Linux cage or confinement_launch for the other host backends, and a verifier checks it against pinned kernel keys.
Without a bound launch record the receipt reads unconfined
A host-executed or in-process tool carries no launch reference. The kernel still admits the call and signs its receipt, which states "not confined by Chio" with the reason taken from its tool_origin. Isolation comes from the host, and a receipt never reads confined on the host's word alone. Process confinement.
A tool with no launch reference still runs under its grant, and its receipt reads “not confined by Chio” with the reason from its tool_origin. A launch record in any state other than FullyEnforced, or a target that exited before dispatch, cannot be bound: the host reaps the child, the kernel signs a deny receipt, and the tool is never relaunched unrestricted. Host backends record their launches as LinuxCage, FirecrackerGuest, ProcessContainer, AgentHostBwrap, or Seatbelt.
In Rust: the verified enforcement record
The parent checks the prepared restrictions and the observed exec together. A close-on-exec status channel reaching EOF corroborates the transition and proves nothing alone; the stopped image must match the retained executable before the host reports a fully enforced state.
pub struct FullyEnforcedEvidence {
pub prepared: EnforcementPrepared,
pub exec_transition: ExecTransitionObserved,
pub status_eof_observed: bool,
}The prepared reader holds one read path, no writes, and no network
The signed-launch example retains a publisher manifest and a separate operator policy for its repository reader. Read their requested and permitted resources together; the query creates no authority.
jq '.body | {
requested_profile: .signed_manifest.manifest.required_permissions.native_syscall_profile,
permitted_profiles: .operator_ceilings.native_syscall_profiles,
read_paths_match: (.signed_manifest.manifest.required_permissions.read_paths
== .operator_ceilings.read_paths),
write_paths: .operator_ceilings.write_paths,
network_destinations: .operator_ceilings.network_destinations,
environment_variables: .operator_ceilings.environment_variables,
descriptor_limit: .limits.nofile_hard
}' profile/launch/cage-launch-policy.json{
"requested_profile": "native_minimal_v1",
"permitted_profiles": [
"native_minimal_v1"
],
"read_paths_match": true,
"write_paths": [],
"network_destinations": [],
"environment_variables": [],
"descriptor_limit": 192
}You should see native_minimal_v1 requested and permitted, the same read path in both authorities, no write paths, no network destinations, no inherited environment variables, and a descriptor ceiling of 192. The projection reviews the contract; the signature check and the observed launch belong to native-start verification, and resource exercises show what the admitted process reaches. Pair each useful action with a plausible escape, read the resource and receiver directly, and keep the evidence for each launch with that launch.
Run the enforcement suite on your host
On the Linux x86_64 host that will launch the reader, run the cage suite against the installed binaries. It draws a fresh random challenge, requires the same value back in the Linux evidence marker, and fails when any required case is missing or ignored.
chio security selftest cage --evidence ./evidencesuite result cage pass Linux enforcement: namespaces, seccomp, Landlock, cgroup v2 1 suite passed; evidence in ./evidence
exact_write_and_read_grants_are_enforced_from_retained_descriptors: admitted reads and writes work and neighbors refuse, under one launch.landlock_denies_ungranted_path_after_fd_based_target_exec: the target cannot read an ungranted path after its transition.independent_seccomp_filter_kills_forbidden_socket: a forbidden socket syscall terminates the probe.launch_prepared_revalidates_mutated_retained_target_before_spawn: a changed retained executable refuses before spawn.
./evidence keeps the challenge, the case inventory, the log, and the exit status, with a SHA-256 manifest over them. The suite exports no signed launch records; take those from your launcher. On a laptop or another host without the Linux mechanisms, chio security selftest cage --portable --evidence ./evidence runs the host-independent cases and says so in its result line; it produces no Linux enforcement evidence.
A forbidden syscall mid-call ends the tool with an incomplete operation
A process can launch fully enforced and later use a forbidden syscall during useful work. The seccomp filter ends it with signal 31 (SIGSYS), and the kernel retains the operation as incomplete beside the verified launch record.
Correlate the tool's terminal record with the host's seccomp audit, which names the syscall by number. x86-64 syscall 72 is fcntl: the minimal profile permits F_SETFD with close-on-exec and excludes F_GETFD, so a file wrapper that issues both stops at the second. Keep the launch, terminal, and operation records, correct the runtime or library behavior against the syscall contract, and repeat the legitimate and refused cases; a wider resource ceiling cannot fix a syscall-argument mismatch.
Lifecycle management holds pidfds, which stay pinned to one process when a numeric PID is reused, and parent-death handling terminates the target when its host dies. That termination says nothing about effects already made, so dispatch recovery keeps the original capture and its unknown outcome. An operation that never captured follows its reversible cleanup; one whose capture may have committed goes to operation recovery.
A host that cannot enforce the profile refuses the launch
The native profile targets Linux x86_64 with kernel 6.7 or later and Landlock ABI 4 or newer. It also requires seccomp filter mode, no_new_privs, sealed memfds, descriptor passing, procfs identity inspection, pidfds, the traced exec transition, close_range, and descriptor-based execution. These commands collect the host facts; the launch handshake itself proves enforcement, and a container's advertised kernel version is insufficient when its security policy blocks a required operation.
uname -srm
cat /sys/kernel/security/lsm
ulimit -n| Failure | Launch behavior | Operator action |
|---|---|---|
| Unsupported kernel or architecture | Refuse launch | Use the supported host profile |
| Partial Landlock enforcement | Terminate and reap | Repair the host prerequisites |
| Target or descriptor mismatch | Refuse launch | Rebuild the resource and executable binding |
| Missing exec event or timeout | Terminate and reap | Inspect the bootstrap and host restrictions |
A child that runs after skipping a failing restriction is a different result, so readiness reports the gap and an unsupported host never serves a weaker profile. The operating-system administrator stays inside the trusted computing base: review service users, executable files, mount permissions, and authority sockets, and apply capacity and lifetime controls to what the tool was deliberately granted.