Control egress
Bind outbound data to a policy-owned destination clearance and test that incompatible disclosure stops before the external effect.
On this page
Send the fixer's summary for issue #417 to the pricing team's inbox and watch the same summary stop at the public pull request. By the end, each destination carries a clearance from your policy, the kernel compares what the work has learned with that clearance before anything leaves, and a receiver you control confirms the refused path sent nothing.
Egress is any operation that moves data past its current protection boundary: an HTTP request, a message to another service, a tool that uploads a file. The same posting tool can be safe for your internal channel and forbidden for a public pull request, so the destination is a security object your configuration owns, never a URL the model supplies.
StartAfter the read of deploy/settings.yaml the retained label is internal. Reading and writing continue; both public posts are refused because internal does not flow to the public clearance.
lattice.rs:65pub fn authorize_egress(
source: &InformationLabel,
clearance: Option<&InformationLabel>,
) -> Result<(), EgressDenial> {
// … Top source, Top or missing clearance: Err
if !source.flows_to(clearance) {
return Err(EgressDenial::FlowViolation);
}
Ok(())
}
1. Bind each destination to a clearance
A network connection has an address, and a disclosure also has an audience. A collaboration service exposes different projects to different people, and one provider endpoint can accept private drafts and public posts alike. Bind each destination to the clearance it accepts in admitted configuration and in the selected adapter, including the account or resource that decides who reads it.
Use the narrowest clearance that names the audience. In the pricing lab, the pricing team's destination keeps the larkfield-pricing reader restriction and the harbor-goods-terms compartment, and the public pull request has the bottom clearance. Before the host acquires outbound execution authority, it compares the resolved source label with that clearance:
use chio_flow::authorize_egress;
use chio_security_types::InformationLabel;
let public = InformationLabel::bottom();
let result = authorize_egress(&session_label, Some(&public));
// Handle a refusal before acquiring outbound execution authority.Here session_label is the label the work has retained across its calls, never a fresh label built from the last message. Which tools egress at all comes from each tool's tool flow declaration, alongside its output label and input clearance. An internal hostname is a naming choice; record what each destination may receive, how its identity is authenticated, and which configuration owner may change the mapping, and review it when a service moves or sits behind another gateway.
2. Refuse an incompatible disclosure before it is sent
The check runs in a fixed order. An unknown source stops disclosure, a missing clearance stops it, a Top clearance is refused as an operational sink, and only a compatible known destination succeeds.
pub fn authorize_egress(
source: &InformationLabel,
clearance: Option<&InformationLabel>,
) -> Result<(), EgressDenial> {
if matches!(source, InformationLabel::Top) {
return Err(EgressDenial::TopSource);
}
let clearance = clearance.ok_or(EgressDenial::MissingClearance)?;
if matches!(clearance, InformationLabel::Top) {
return Err(EgressDenial::TopClearance);
}
if !source.flows_to(clearance) {
return Err(EgressDenial::FlowViolation);
}
Ok(())
}authorize_ result | Inspect | Corrective action |
|---|---|---|
Err(Egress | Policy's destination mapping | Add a reviewed mapping for the destination |
Err(Egress | Retained state and classification history | Recover verified evidence or a valid isolation transition |
Err(Egress | Destination policy | Replace the unknown clearance with a known contract |
Err(Egress | Owners, readers, and compartments | Choose a compatible destination |
These are the values the pure function returns and the pricing lab prints. A serving host records them under its own FlowDenial text, such as "source exceeds policy clearance" or "top is not an operational clearance", and the tool caller receives the host's refusal below. A missing clearance can stop sooner: registry admission refuses an egressing tool whose operator policy configures none, before any call.
| Host | Tool caller receives | Flow reason recorded in |
|---|---|---|
Linux chio process host with a native broker, checking at dispatch capture | Signed Deny, reason "security dispatch outcome requires reconciliation: security native dispatch capture callback failed; authoritative recovery required" | Host warning log, "native flow policy denied dispatch" |
Kernel from build_, checking before invocation | Signed Deny, reason guard denied the request: guard "chio- | Receipt guard evidence, "flow denied: <flow denial>" |
The flow check is one of four independent checks on the same effect. Capability scope reads the authenticated grant and route; information flow reads retained state and the policy clearance; network policy reads the executor's operator-owned rules; the credential broker reads the bound request and credential version.
Another tool, destination, endpoint, method, path, body, or credential needs a fresh evaluation, so carry the request commitment, destination, and state generation through the operation-owned dispatch path, revalidate authority at handoff, and give the executor no fallback route after a refusal. For credentialed HTTP calls the broker also binds scheme, host, explicit port, path and query, and method, with constraints on body and headers.
Run the decision matrix without a network
Run the destination decision matrix
A customers table read owned by larkfield-pricing reaches its internal destination and is refused at a public receiver. The first four cases keep the source fixed and change the destination clearance; the last substitutes an unknown source. From the security-research project, run cargo run --locked --quiet --example egress_matrix:
use chio_flow::{authorize_egress, EgressDenial};
use chio_security_types::{InformationLabel, PrincipalId};
use std::collections::{BTreeMap, BTreeSet};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let owner = PrincipalId::new("larkfield-pricing")?;
let internal = InformationLabel::try_known(
BTreeMap::from([(owner.clone(), BTreeSet::from([owner]))]),
BTreeSet::new(),
)?;
let public = InformationLabel::bottom();
let unknown = InformationLabel::Top;
let cases = [
("internal", &internal, Some(&internal), Ok(())),
(
"public",
&internal,
Some(&public),
Err(EgressDenial::FlowViolation),
),
(
"undeclared",
&internal,
None,
Err(EgressDenial::MissingClearance),
),
(
"top clearance",
&internal,
Some(&unknown),
Err(EgressDenial::TopClearance),
),
(
"unknown source",
&unknown,
Some(&internal),
Err(EgressDenial::TopSource),
),
];
for (name, source, clearance, expected) in cases {
let actual = authorize_egress(source, clearance);
assert_eq!(actual, expected, "destination case: {name}");
println!("{name}: {actual:?}");
}
Ok(())
}internal: Ok(()) public: Err(FlowViolation) undeclared: Err(MissingClearance) top clearance: Err(TopClearance) unknown source: Err(TopSource)
Missing clearance, an unknown source, an operational Top clearance, and an incompatible known label are four failure classes; keep them distinct in diagnostics. The program opens no network connection, so an adapter test still has to show that a refused decision leaves the receiver with no protected request.
3. Start a receiver you control
Evidence of a refused disclosure comes from the destination as well as the caller. The pricing lab's connector fixes two loopback routes, and each request selects internal or public, never an arbitrary URL. From the host/ directory of the built host, start a fresh receiver and leave it running:
python3 receiver.py .egress-receiverYou should see the receiver hold the terminal. It records each request it receives, with the payload and its SHA-256 digest, in one journal file per route, independent of the agent and the connector.
4. Send one summary to both destinations
In a second terminal, read Harbor Goods' terms, then post the same summary to each destination. The public attempt is expected to exit with status 2; the script keeps that status visible instead of hiding it in a pipeline.
research init --state .egress
research read terms --as fixer --operation terms-v1 --state .egress --json > terms.json
printf '%s\n' 'quote() applies the tier discount for sku-204 across both price lists; a new test covers the split order.' > summary.txt
research publish summary.txt \
--as fixer --operation internal-v1 \
--to internal --state .egress --json > internal.json
if research publish summary.txt \
--as fixer --operation public-v1 \
--to public --state .egress --json > denied.json; then
echo 'Unexpected public release' >&2
exit 1
else
test "$?" -eq 2
fi
jq '{verdict, reason, output}' denied.json{
"verdict": "Deny",
"reason": "security dispatch outcome requires reconciliation: security native dispatch capture callback failed; authoritative recovery required",
"output": null
}You should see a signed Deny with the native host's dispatch reconciliation reason, the first row of the serving-host table. With RUST_LOG=warn set, the host's warning log names the flow-policy refusal. The caller's grant includes both publisher tools, so the refusal comes from the retained data policy and the terms read that raised it.
5. Confirm the public receiver saw nothing
A reconciliation reason alone leaves the effect open, so read the receiver's journals:
wc -l < .egress-receiver/internal.jsonl
test -e .egress-receiver/public.jsonl && wc -l < .egress-receiver/public.jsonl || echo 01 0
You should see one internal request and no public one. The internal journal holds the summary bytes and their digest; the signed Deny in denied.json is the kernel's record of the refusal. Stop the receiver with Ctrl-C when you finish.
A public release is its own operation, and the refused case is a regression test
When the task needs public output, finalize the bytes and the destination first, then request the exception for exactly that disclosure. A reviewed summary and the full source document are different disclosures, and a grant for the summary gives an upload tool no later choice of file or destination. Declassification authorizes one exact release; afterwards the principal and lineage keep the source restrictions, and the next public request meets its own flow check.
Repeat the refused case after another public read, through a delegated child, and after reopening the application's durable flow state. These variations exercise the retained-history contract as well as the label comparison, and they belong in the suite for every new posting tool or adapter change. When the concern is a compromised process going around the connector, add confinement.
No receipt appeared
A malformed request or a failed adapter precondition can stop before kernel evaluation. That still prevents the effect, and it is different evidence from an evaluated flow denial, which the native process host names in its log and an active-defense kernel names in receipt guard evidence. Keep the bridge error, the kernel decision if present, the host log, and the receiver observation, so the test credits the component that stopped the request.