Choose a confinement profile
substrate_host::confinement, in the b10x-substrate-host crate, is the in-process API an
embedder uses to run a command under confinement without a daemon. It is the host crate's API, not
the wire contract: no contract bundle changes with it, and it is a development surface.
A run takes three steps. Open an adapter, which proves what it can do. Describe what the run needs
as a ConfinementProfile. Have the adapter admit the profile, then start the command under it.
Anything the adapter did not prove is refused by name. Nothing falls back to another adapter, and
nothing is quietly dropped.
Open what the host offers
confinement::open takes one AdapterRequest: Local, Linux, Macos, Systemd, Oci or
Pod, each carrying its own settings. An adapter that cannot open on this host answers
confinement.adapter-unserved. Open every adapter your configuration offers and keep the ones that
opened; which one opened is not something to branch on.
| Adapter | What it is |
|---|---|
local | an unconfined host process, always declared insecure |
linux | the host driver: bubblewrap, namespaces, cgroup v2, seccomp |
macos | a Seatbelt profile |
systemd | a transient systemd scope around local or linux |
oci | one Docker or podman container per run, the image named by digest |
pod | one Kubernetes Pod per run, the image named by digest |
More columns: swipe horizontally, or focus the table and use the arrow keys.
Confinement adapters explains what each one enforces, declares and refuses.
Read the facts
adapter.facts() returns AdapterFacts, read once at open and never raised afterwards:
served_families: the contract families the adapter serves, in this order:process_run,process_watch,output_read,wait,workspace,filesystem_view,network_egress,network_ingress,limits,audit.serves(&family)asks for one.facts: what the adapter proved, in this order:declared_insecure,paths_are_host_paths,whole_tree_kill,local_permitted,ipc_socket_allowlist,ipc_signals_contained,sealed_paths,exec_subpaths,private_tmpdir,host_network,image_digest_pinned.proves(&fact)asks for one.
Two facts change how you write the profile and the command:
declared_insecure: the run is not confined. The profile must carry alocal_reasonthat is not blank, and every observation of the run carries that reason.paths_are_host_paths: the run sees host paths. Without it, the run sees the paths the profile mounts: underlinuxthe workspace is/workspaceand each root is at itsmount.
What each adapter serves depends on the host. linux serves a family only when the driver's probe
proved the mechanism behind it at open, so limits is absent without a delegated cgroup v2 root,
and filesystem_view is absent without proven read-only roots and files. local serves
process_run, process_watch, output_read, wait, workspace and audit, and nothing that
would claim confinement. No adapter serves network_ingress.
Describe the run
Each profile field needs the family or the fact beside it.
| Field | Needs |
|---|---|
families, facts | themselves, beyond what the fields imply |
workspace, workdir | workspace |
workspace_access other than ReadWrite | workspace |
output_retention other than Head on both streams | output_read |
writable_roots, read_only_roots, read_only_files | filesystem_view |
sealed_paths | the fact sealed_paths |
exec_subpaths | the fact exec_subpaths |
private_tmpdir | the fact private_tmpdir |
network: Egress | network_egress |
network: Isolated | network_egress, on an adapter that proves host_network |
ipc.socket_allowlist | the fact ipc_socket_allowlist |
ipc.signals_contained | the fact ipc_signals_contained |
process_bounds, io_bounds | limits |
stdin: Piped | process_run and the fact stdin_pipe |
output_delivery: Live | output_read and the fact output_live; Head retention on both streams |
audit | audit; admission, refusal and start records go to the sink |
image | on an adapter that proves image_digest_pinned, name@sha256: and 64 lowercase hex digits |
More columns: swipe horizontally, or focus the table and use the arrow keys.
ingress_routes and rate_bounds are reserved: any value is
confinement.reserved-field-unserved. profile.required_facts() lists the facts a profile
requires, in the order above. profile.required_families() lists the families its families and
its fields imply, in the order above, except two that depend on more than the profile's fields:
audit for an audit sink, and network_egress for network: Isolated on an adapter that proves
host_network. Admission requires both, so add them yourself (see needed below).
A workspace is the directory the run starts in and may write, so a build that writes target/
inside it needs no more than workspace. A directory outside it is a declared root:
read_only_roots for a toolchain, writable_roots for an install root the operator declared by
name.
workspace_access: Scoped names the subtrees a run may write; the rest of the workspace is
read-only. linux and systemd around it serve it. The run cannot write the root's .git, and a
subtree that is .git, lies beneath it or holds a submodule the root's .gitmodules registers is
refused (confinement.writable-subtree-git). Limits: an unregistered nested .git stays writable,
and host Git run inside a subtree reads a repository the run created there, so run it in the root.
A hard link made on the host before the run stays writable through a subtree; the run cannot
create one (EXDEV).
Choose an adapter by its facts
Pick the adapter whose facts cover the profile, and prefer one that does not prove
declared_insecure:
use substrate_host::confinement::{
self, AdapterFacts, ConfinementProfile, ContractFamily, FAMILY_ORDER, Fact, Network,
};
/// The families admission requires of this adapter, in the contract's order.
fn needed(profile: &ConfinementProfile, facts: &AdapterFacts) -> Vec<ContractFamily> {
let implied = profile.required_families();
FAMILY_ORDER
.into_iter()
.filter(|family| {
implied.contains(family)
|| (*family == ContractFamily::Audit && profile.audit.is_some())
|| (*family == ContractFamily::NetworkEgress
&& profile.network == Some(Network::Isolated)
&& facts.proves(&Fact::HostNetwork))
})
.collect()
}
fn covers(profile: &ConfinementProfile, facts: &AdapterFacts) -> bool {
needed(profile, facts).iter().all(|family| facts.serves(family))
&& profile.required_facts().iter().all(|fact| facts.proves(fact))
}
let offered: Vec<_> = requests
.into_iter()
.filter_map(|request| confinement::open(request).ok())
.collect();
let chosen = offered
.iter()
.filter(|adapter| covers(&profile, adapter.facts()))
.min_by_key(|adapter| adapter.facts().declared_insecure());
covers is a preselection; admission is the answer. An adapter can refuse what its families do
not show: linux refuses a workdir its host has not proved with exec.workdir-unserved, and a
writable root the operator did not declare with exec.writable-roots-host-unserved. When an
adapter does not cover a profile, admitting it there is refused in the order of the refusals table
below. Past its first five rows, that is confinement.family-unserved naming the first family of
needed the adapter does not serve, or, when it serves them all,
confinement.fact-unserved naming the first fact of required_facts() it did not prove. Either
can be read from the facts before the call.
Admit, start and observe
let admitted = adapter.admit(&profile)?;
let run = adapter.start(&admitted, &command).await?;
let ended = adapter.wait(&run).await?;
let output = adapter.output(&run).await?;
Command holds the argv and the whole environment; nothing is inherited from the embedding
process. A profile admitted by one adapter starts only on that adapter, and a start decides the
profile again against the adapter's own facts. observe, signal and render (the program and
arguments a start would run, without running it) take the same run or admitted profile.
Build with the macOS developer tools
Inside the Seatbelt policy, the /usr/bin/cc and xcrun shims cannot find the compiler or the
SDK: they ask xcodebuild through /bin/sh, which no exec subpath allows. So macos resolves the
selected developer tools once, when it opens. It runs /usr/bin/xcrun --sdk macosx outside the
sandbox for the SDK path, clang and ar, with a cleared environment (PATH=/usr/bin:/bin and
nothing else) and / as its working directory, so no DEVELOPER_DIR or TOOLCHAINS of the
embedding process chooses the answer. Each answer must be an absolute path that resolves beneath
the selected developer tools. On a host without developer tools, xcrun is not run.
A start then gets these variables, each only when its path lies beneath one of the profile's
exec_subpaths, and only when the command's environment does not name it:
| Variable | Value |
|---|---|
SDKROOT | the macOS SDK |
CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER | clang |
CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER | clang |
CC | clang |
AR | ar |
More columns: swipe horizontally, or focus the table and use the arrow keys.
A confined cargo build or cargo test, and a build script that compiles C, then link without
reaching the shim. A linker variable outranks a linker a cargo configuration file names; to use
another, set the variable in the command's environment. Nothing is resolved again at a start, and
the Seatbelt policy gains no rule.
A start whose exec subpaths reach the developer tools is refused
exec.sandbox-developer-tools-unresolved (501), naming why, before anything runs, when:
- the open could not resolve the SDK,
clangorar; - other developer tools are selected than at open, including when the open found none;
- the SDK the open resolved is no longer a directory beneath them;
- the exec subpaths hold the selected developer directory (an Xcode bundle's
Contents/Developer, or the Command Line Tools directory) but not a value it needs.
Refusals
Admission refuses in this order and names the first refusal it meets:
| Order | Code | Status | When |
|---|---|---|---|
| 1 | confinement.reserved-field-unserved | 501 | a reserved field is set |
| 2 | confinement.adapter-unserved | 501 | the adapter did not open, or the profile's inner is not the adapter it wraps |
| 3 | confinement.local-reason-missing | 422 | a declared-insecure adapter and no reason |
| 4 | confinement.image-unpinned | 422 | a digest-pinning adapter and an image not named by digest |
| 5 | confinement.local-refused | 403 | a declared-insecure adapter the host's policy forbids |
| 6 | confinement.family-unserved | 501 | the first family of needed not served |
| 7 | confinement.fact-unserved | 501 | the first required fact not proved |
More columns: swipe horizontally, or focus the table and use the arrow keys.
error.code() and error.status() return the code and status. A refusal from the host itself,
met while starting, keeps its own code, such as exec.workdir-missing.
A start or a render whose profile names no workspace is confinement.workspace-required (422)
on every adapter that runs the program in a host directory. pod runs it in its own emptyDir at
/workspace instead.
The host's operator can forbid local by setting SUBSTRATE_CONFINEMENT_LOCAL to any value other
than permit; an embedder can only tighten that, with LocalPolicy::Refuse.
Read confinement and refusal for the guarantees the linux adapter's
host driver enforces.