Chio/Docs
LOGIN · JOIN

Control egress

Bind outbound data to a policy-owned destination clearance and test that incompatible disclosure stops before the external effect.

Guide chio@5f63e6e

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.

Start
The retained label rises at the internal read; public posts are refused while it exceeds the public clearanceℓtinternalpublic ⊥c(d) = ⊥public review
ℓ4⋢c(d)\htmlClass{role-deny}{\ell_4 \not\sqsubseteq c(d)}
ℓ5⋢c(d)\htmlClass{role-deny}{\ell_5 \not\sqsubseteq c(d)}
x1readREADME.mdx2readdeploy/settings.yamlx3writesummaryx4postsummaryx5postcommentno grantclick an underlined label to change the run
ℓt+1=ℓt⊔ℓ(xt)\htmlClass{role-call}{\ell_{t+1}} = \htmlClass{role-call}{\ell_t} \sqcup \ell(x_t)
allow(xt)  ⟺  ℓt⊑c(d)\text{allow}(x_t) \iff \htmlClass{role-call}{\ell_t} \sqsubseteq \htmlClass{cor-relation}{c(d)}
The retained label rises at the internal read; public posts are refused while it exceeds the public clearancetℓpublic ⊥internalc(d) = ⊥public review
ℓ4⋢c(d)\htmlClass{role-deny}{\ell_4 \not\sqsubseteq c(d)}
ℓ5⋢c(d)\htmlClass{role-deny}{\ell_5 \not\sqsubseteq c(d)}
x1readREADME.mdx2readdeploy/settings.yamlx3writesummaryx4postsummaryx5postcommentno grantclick an underlined label to change the run

After 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.

ℓt+1=ℓt⊔ℓ(xt)allow(xt)  ⟺  ℓt⊑c(d)\begin{aligned} \htmlClass{role-call}{\ell_{t+1}} &= \htmlClass{role-call}{\ell_t} \sqcup \ell(x_t) \\ \text{allow}(x_t) &\iff \htmlClass{role-call}{\ell_t} \sqsubseteq \htmlClass{cor-relation}{c(d)} \end{aligned}
In Rust · lattice.rs:65
pub 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(())
}
Calls from the pull request #421 review, drawn on two points of the lattice; real labels carry owners, readers, and compartments. ℓ is the label retained for the principal, lineage, and session, and only calls that egress meet a clearance.Information flow

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:

src/egress.rsrust
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.

crates/security/chio-flow/src/lattice.rs65–80rust
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_egress resultInspectCorrective action
Err(EgressDenial::MissingClearance)
Policy's destination mapping
Add a reviewed mapping for the destination
Err(EgressDenial::TopSource)
Retained state and classification history
Recover verified evidence or a valid isolation transition
Err(EgressDenial::TopClearance)
Destination policy
Replace the unknown clearance with a known contract
Err(EgressDenial::FlowViolation)
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.

HostTool caller receivesFlow 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_kernel_with_active_defense, checking before invocation
Signed Deny, reason guard denied the request: guard "chio-flow-pre-invocation" denied the request
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:

security-research/examples/egress_matrix.rsrust
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(())
}
Output
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:

Terminal 1bash
python3 receiver.py .egress-receiver

You 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.

Terminal 2bash
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
Output
{
  "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:

Terminal 2bash
wc -l < .egress-receiver/internal.jsonl
test -e .egress-receiver/public.jsonl && wc -l < .egress-receiver/public.jsonl || echo 0
Output
1
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.

Next: release one reviewed summary