Chio/Docs
LOGIN · JOIN

Process confinement

Give a native tool exactly the files, descriptors, and syscalls its task needs, and see its receipt say so.

Concept chio@5f63e6e

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.

Retained grantChoose what the tool reaches for.
retained at compile timeoutside the grantreadf₁admitted fileexisting.datacreatef₂result fileexact new fileEACCESℓlate hard linksame directoryseccompnnetworksocket()ptool processLandlock, seccompretained at compile timeoutside the grantreadf₁admitted fileexisting.datacreatef₂result fileexact new fileEACCESℓlate hard linksame directoryseccompnnetworksocket()ptool process

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.

One confined tool process and four things it may try to reach, with the grant compiled before launch. File names are illustrative; the descriptor table, the brokered profile's preconnected descriptor, and process limits are left out.

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:

linux_enforcement.rs · excerptrust
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();
Output
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.

late-link-probe.c · excerptc
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.

Confinement bindingChoose an outcome.
signed registryresource ceilingLinux cageLandlock · seccompdispatchfails closedexec observedλlaunch recordnative_launchsigns at dispatchσπrKpρpublisheroperatorrequestkernelhost-executed toolnot confined by Chiohost-executedregistryceilingLinux cageLandlock · seccompdispatchfails closedλlaunch recordnative_launchsignsσπrKpρpublisheroperatorrequesthost toolnot confined by Chiohost-executed

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.

One native tool call from request to receipt: the publisher and operator authorities, the Linux cage boundary, and the launch record the kernel binds at dispatch. The descriptor table, the syscall allowlist, and worker-lane confinement are left out.

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.

crates/security/chio-cage/src/enforcement.rs183–187rust
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.

Terminalbash
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
Output
{
  "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.

Terminalbash
chio security selftest cage --evidence ./evidence
Output
suite            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.

Terminalbash
uname -srm
cat /sys/kernel/security/lsm
ulimit -n
FailureLaunch behaviorOperator action
Unsupported kernel or architectureRefuse launchUse the supported host profile
Partial Landlock enforcementTerminate and reapRepair the host prerequisites
Target or descriptor mismatchRefuse launchRebuild the resource and executable binding
Missing exec event or timeoutTerminate and reapInspect 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.

Next: bind a reviewed tool to the process that starts