Spec layer 0 — Module io_uring, Stage 3f: confinement (sandbox)
Technical specification — Version 1.0 (target kernel: Linux 6.12 LTS)
Position. Stage 3f makes soundness decision S3 from the master document operational: confine a ring so that it can only submit operations that have been explicitly authorized. Sub-module
air-sys-syscall::io_uring::sandbox. Builds on theIORING_SETUP_R_DISABLEDflag (Stage 1), and the register opcodesREGISTER_RESTRICTIONS(11) andENABLE_RINGS(12). This is the io_uring brick of Air’s capabilities model (ADR-001 AirCom, ADR-010 signed entitlements), in defense in depth withfamily-security(seccomp/Landlock).
1. Why confine a ring?
io_uring executes operations (read, openat2, connect…) that, when submitted
through the ring, do not go through the classic syscall interface. A
seccomp filter that blocks the openat2 syscall does not prevent an
IORING_OP_OPENAT2 submitted via the ring. Historically, this has been a bypass
path for sandboxes. The proper response: restrict the ring itself.
A confined ring can only submit the opcodes, register-ops, and SQE flags explicitly placed on the allowlist, and the kernel enforces this. Even a compromised service cannot expand what its ring permits.
This is the materialization, at the io_uring level, of the entitlements
declared in the signed manifest of an .airservice/.airapp (ADR-010), and
the kernel-side counterpart of AirCom capabilities (ADR-001).
2. The mechanism in three stages
- Create disabled: the ring is created with
IORING_SETUP_R_DISABLED— it exists but cannot submit anything. - Restrict:
REGISTER_RESTRICTIONSapplies a list of restrictions (allowlist). Possible only while the ring is disabled. - Enable:
REGISTER_ENABLE_RINGSactivates the ring. After activation, restrictions are immutable — they can no longer be relaxed.
The order is kernel-enforced and reflected by the API: a ring that is already active cannot be restricted, and submission is not possible before activation.
3. API
3.1 RestrictionSet
#![allow(unused)]
fn main() {
/// Allowlist of what a confined ring will be permitted to do. Default-deny: anything
/// not explicitly authorized is refused by the kernel.
#[derive(Debug, Clone, Default)]
pub struct RestrictionSet { /* ... */ }
impl RestrictionSet {
pub fn new() -> Self;
/// Authorizes a submission opcode (RESTRICTION_SQE_OP).
pub fn allow_op(self, op: IoUringOpcode) -> Self;
/// Authorizes a register opcode (RESTRICTION_REGISTER_OP).
pub fn allow_register(self, op: RegisterOp) -> Self;
/// Authorized SQE flags (RESTRICTION_SQE_FLAGS_ALLOWED).
pub fn allow_sqe_flags(self, flags: SqeFlagSet) -> Self;
/// SQE flags required on EVERY submission (RESTRICTION_SQE_FLAGS_REQUIRED).
pub fn require_sqe_flags(self, flags: SqeFlagSet) -> Self;
/// Conversion from an entitlement set (upper layer).
/// Provided as an integration point; policy lives in layer 5.
pub fn from_entitlements(/* … */) -> Self;
}
}
3.2 Integration with the builder (Stage 1)
#![allow(unused)]
fn main() {
// IoUringBuilder::restrict(&[Restriction]) → R_DISABLED + REGISTER_RESTRICTIONS.
let mut ring = IoUringBuilder::new(entries)
.restrict(restrictions.as_slice()) // ring created disabled
.build()?;
ring.enable()?; // REGISTER_ENABLE_RINGS; immutable thereafter
// From this point, any submission outside the allowlist fails (-EACCES/-EINVAL).
}
build()with a non-emptyrestrictleaves the ring disabled;enable()(Stage 1 §5.2) activates it. Any attempt to callsubmit_*beforeenable()⇒ error. Any attempt to re-restrict afterenable()⇒ error.- Default-deny semantics: if
RestrictionSetcontains at least oneallow_op, only the listed opcodes are permitted (the rest are refused by the kernel). Same for register-ops.
4. The four restriction types
Type (io_uring_register_restriction_op) | RestrictionSet | Effect |
|---|---|---|
RESTRICTION_SQE_OP (1) | allow_op | allowlist of submittable opcodes |
RESTRICTION_REGISTER_OP (0) | allow_register | allowlist of register-ops |
RESTRICTION_SQE_FLAGS_ALLOWED (2) | allow_sqe_flags | authorized SQE flags |
RESTRICTION_SQE_FLAGS_REQUIRED (3) | require_sqe_flags | flags required on each SQE |
Example — a “network only” AirCom service:
#![allow(unused)]
fn main() {
let r = RestrictionSet::new()
.allow_op(IoUringOpcode::Socket)
.allow_op(IoUringOpcode::Connect)
.allow_op(IoUringOpcode::Send)
.allow_op(IoUringOpcode::Receive)
.allow_op(IoUringOpcode::SendZeroCopy)
.allow_op(IoUringOpcode::Close);
// The ring can never emit openat2, unlinkat, etc. — kernel-guaranteed.
}
5. Relationship with Air’s security model
- ADR-010 (signed entitlements): the manifest of an
.airservicedeclares its entitlements; a layer 5 component (air-launchd/air-trust) translates those entitlements into aRestrictionSetand provides the service with an already-confined ring. Layer 0 provides the mechanism (RestrictionSet,restrict,enable); policy lives in the upper layer — a clean separation. - ADR-001 (AirCom): a service receives only the capabilities (FDs) from its manifest, and its ring can only submit the corresponding operations — a double lock (unforgeable FDs + restricted ring).
family-security(seccomp/Landlock): io_uring restrictions complement seccomp/Landlock; they do not replace them. seccomp filters syscalls, Landlock filters FS access, io_uring restrictions filter ring operations — defense in depth. This is precisely what closes the bypass path described in §1.- Personality (Stage 3a): combined with restrictions, allows ops to be executed under a controlled identity — least privilege.
Out-of-scope note. Even finer-grained filtering via a BPF program (
IORING_REGISTER_BPF_FILTER) is post-6.12; it may enrich this confinement later, through runtime detection, without amendment.
6. Summary
| Element | Facade |
|---|---|
SETUP_R_DISABLED | IoUringBuilder::restrict (creates disabled) |
REGISTER_RESTRICTIONS (11) | IoUringBuilder::restrict (applies the list) |
REGISTER_ENABLE_RINGS (12) | IoUring::enable |
4 restriction_op | RestrictionSet::{allow_op, allow_register, allow_sqe_flags, require_sqe_flags} |
7. Added / shared types
New: RestrictionSet, RegisterOp (enumeration of authorizable register-ops),
SqeFlagSet. Reused: Restriction and IoUringOpcode (declared in Stage 1),
IoUringBuilder::restrict, IoUring::enable. Integration point
from_entitlements (entitlement type defined in the upper layer).
8. Test strategy
- Integration: create an
R_DISABLEDring, apply an allowlist,enable(), verify that an authorized opcode passes and that a non-authorized opcode fails (-EACCES/-EINVAL); same for register-ops;require_sqe_flagsenforced (a submission without the required flag fails); submission beforeenablerejected; re-restriction afterenablerejected (immutability). - Security: test demonstrating that an
openat2submitted via a “network only” ring is refused even when the process has the right to open the file (proof of defense in depth vs seccomp). - Property-based: for any subset of authorized opcodes, exactly those opcodes pass.
- Coverage: 100% lines + branches.
9. Key decisions that emerged at Stage 3f
- Confinement = layer 0 mechanism, layer 5 policy —
RestrictionSetand therestrict → enableflow are neutral; the translation of signed entitlements (ADR-010) lives in the upper layer. - Default-deny — as soon as an opcode allowlist is set, everything else is refused by the kernel; no half-measures.
- Immutability after
enable— guaranteed by the kernel and reflected by the API; a confined ring cannot be relaxed. - Explicit defense in depth — io_uring restrictions complement seccomp/Landlock and close the syscall filter bypass path.
- BPF filtering noted but out of scope (post-6.12).
10. Next steps
Next and final spec in the module: io-uring-4-raw-en.md (level-1 access to the
ring protocol: RawSubmissionQueueEntry/RawCompletionQueueEntry, direct SQE/CQE manipulation, not-yet-wrapped
operations, extensive # Safety). Global English translation after validation of
the French documents.
Document license: MPL 2.0
Status: Technical specification for Stage 3f (confinement) of the air-sys-syscall::io_uring module, target kernel 6.12 LTS.