Chio/Docs

PlatformFoundations

Fail-Closed Semantics

Guard errors, timeouts, lock failures, and parse failures deny the call. The kernel signs deny and allow receipts.

Chio TCB ringsHardware: CPU · RAM · hwRNGOS / runtime: Linux · libc · tokioRust compiler + std: rustc · llvm · std · cargoCrypto primitives: libsodium · ed25519-dalek · sha2Chio core: chio-kernel-coreHardwareCPU · RAM · hwRNGOS / runtimeLinux · libc · tokioRust compiler + stdrustc · llvm · std · cargoCrypto primitiveslibsodium · ed25519-dalek · sha2Chio corechio-kernel-coreHardware · trustedno software can verify; ring 0 attestation needs a TEEOS / runtime · auditedkernel, syscalls, allocator, async runtimeRust compiler + std · auditedcompiler correctness and std-library invariantsCrypto primitives · verifiedconstant-time, audited, well-known APIsChio core · formally specifiedthe smallest TCB; subject of Lean 4 proofssmallest TCB at the center; assumptions on outer rings tracked in formal/assumptions.toml
The Chio core is the smallest trusted computing base. Outer rings add the assumptions listed in `formal/assumptions.toml`.

The Invariant

The kernel's pre-dispatch guard loop maps each guard result to the invariant. Each guard returns a GuardDecision (a Verdict plus evidence) or an Err; the loop turns any deny, any unsupported approval verdict, and any error into KernelError::GuardDenied:

crates/kernel/chio-kernel/src/kernel/dispatch.rs
for guard in guards {
    match guard.evaluate(ctx) {
        Ok(decision) => {
            evidence.extend(decision.evidence);
            match decision.verdict {
                Verdict::Allow => { /* passed; keep iterating */ }
                Verdict::Deny => {
                    return Err(GuardRunError::new(
                        KernelError::GuardDenied(format!(
                            "guard \"{}\" denied the request",
                            guard.name()
                        )),
                        evidence,
                    ));
                }
                Verdict::PendingApproval => {
                    // The Guard trait does not carry the HITL approval flow;
                    // a bare guard returning PendingApproval is unsupported,
                    // so fail closed.
                    return Err(GuardRunError::new(
                        KernelError::GuardDenied(format!(
                            "guard \"{}\" returned an unsupported approval verdict",
                            guard.name()
                        )),
                        evidence,
                    ));
                }
            }
        }
        Err(e) => {
            // Fail closed: guard errors are treated as denials.
            return Err(GuardRunError::new(
                KernelError::GuardDenied(format!(
                    "guard \"{}\" error (fail-closed): {e}",
                    guard.name()
                )),
                evidence,
            ));
        }
    }
}

Two consequences:

  • A returned Err and a returned Ok(Deny) both short-circuit. The receipt distinguishes the two by the message (the error path includes (fail-closed)).
  • The loop never silently downgrades to Allow on failure. There is no best-effort mode for the synchronous catalog.

Load-Time Cryptographic Floor

The kernel also enforces a process-wide cryptographic floor on each signed record: receipts, capability tokens, compliance certificates. It is loaded once at start and threaded through the receipt signer, the capability validator, and the compliance-certificate issuer. The floor is a CryptoFloor enum in chio-policy:

crates/guards/chio-policy/src/crypto_floor.rs
pub enum CryptoFloor {
    AllowClassical, // accept classical-only envelopes (Ed25519, P-256, P-384). Default.
    AllowHybrid,    // accept classical-only or hybrid classical-plus-ML-DSA-65. Needs a PQ key.
    PqRequired,     // reject classical-only; every artifact must be hybrid. Needs a PQ key.
}

The variants are strictly ordered, AllowClassical < AllowHybrid < PqRequired, and serialize as allow_classical, allow_hybrid, and pq_required. The kernel validates the floor at policy load. CryptoFloor::validate_with_pq_key runs at policy load: selecting AllowHybrid or PqRequired without a provisioned ML-DSA-65 key returns CryptoFloorLoadError::HybridFloorRequiresPqKey and the kernel refuses to start before its first signing call. A misconfigured deployment therefore fails at boot.

A sibling WeightsCardRequired enum (Disabled, Required, RequiredWithPin) applies the same load-time requirement to model provenance: set above Disabled, it makes a signed weights/model-card binding mandatory on every provider bind and rejects a bind that arrives without one.


Failure-Mode Matrix

The table records how each failure reaches a deny: through the pipeline or through the kernel that wraps it.

FailureVerdictReasonSide Effects
Panic in evaluateDeny / process abortNo blanket catch_unwind. An offloaded guard's panic surfaces as a JoinError mapped to KernelError::Internal; an inline panic aborts the process under panic = "abort".Guards must not panic; the workspace denies unwrap/expect on guard and receipt paths.
Mutex poisonedDenyGuard returns KernelError::Internal; pipeline maps to GuardDenied.Lock state stays poisoned; subsequent calls also deny until the guard is restarted or its state is cleared.
WASM fuel exhaustedDenyWasmtime aborts execution; the WASM-host guard returns an error.Per-call deadline metric increments; module instance is reaped.
Circuit breaker openDeny by defaultCircuitOpenVerdict::Deny is the adapter default.No external call. Operators can opt into Allow per-adapter for advisory deployments.
Parse error on inputDenyGuard returns KernelError::Internal.Receipt records the guard name and a fail-closed marker; raw input is never echoed.
Regex compile failure (load time)N/ACaught at construction; the guard never reaches the pipeline.Kernel start-up fails. Misconfiguration is a load-time error, not a runtime denial storm. One narrow exception: a ResponseSanitizationGuard custom pattern that fails to compile through build_pattern is silently dropped: the guard still builds, that single pattern goes unenforced. See Failure Recovery.
Network timeout (external guard)Deny after retries exhaustedretry_with_jitter retries until RetryConfig.max_retries; final failure becomes Verdict::Deny.Failure recorded on the breaker; cumulative failures may open it.
Journal unavailable (session-aware guard)DenyGuard returns KernelError::Internal.tracing event with guard name; receipt records the deny.
Rate limiter emptyDeny by defaultRateLimitedVerdict::Deny is the adapter default.No external call; no breaker increment.
Ambiguous input (cannot decide allow vs deny)DenyAuthor convention: when the guard cannot prove allow, return deny.Receipt records the deny with the guard's reason.
Internal exception (any other)DenyKernelError mapped to GuardDenied with (fail-closed) tag.tracing event; receipt persisted.

How Panics Are Handled

There is no blanket panic-catching boundary around inline guard evaluation. In the default path guards run inline in evaluate_guards_sequential, and release builds compile with panic = "abort", so a panic inside a guard aborts the process rather than unwinding into a verdict. Guards should not panic: the workspace sets clippy::unwrap_used = "deny" and clippy::expect_used = "deny", because an unhandled Option/Result panic on a guard or receipt path is an availability fault, not an acceptable shortcut.

A panic is isolated only when guards are offloaded onto tokio::task::spawn_blocking, which happens under a configured guard budget or always_offload_guards. There Tokio's blocking-pool handling catches the unwind and surfaces it as a JoinError, which the kernel maps to KernelError::Internal("guard task join failed: ..."). The request fails closed and the async worker pool keeps serving.


Emergency Stop

Two process-wide kill switches force deny-all independent of any single guard, for the case where an operator needs to freeze a running kernel without tearing it down.

At the kernel level, ChioKernel::emergency_stop(reason) sets a flag that every evaluate_tool_call* path checks first. While it is engaged, each call returns a signed deny receipt carrying EMERGENCY_STOP_DENY_REASON ("kernel emergency stop active") before capability validation or the guard pipeline runs. emergency_resume() disengages it and is_emergency_stopped() reports the current state. The kernel stays live so orchestrators and health probes see a running process while all evaluated calls receive a deny.

HushSpec carries a second, independent switch. activate_panic(), deactivate_panic(), and is_panic_active() in chio-policy set a global flag consulted by each policy evaluate() call. The built-in chio:panic ruleset: one of the seven embedded rulesets a policy can extends. Both mechanisms deny requests, but they operate at different layers and can be activated independently.


The Advisory Exception

The AdvisoryPipeline records signals and lets successful requests proceed. Two cases can still deny a request:

  • Promotion to deny. If a PromotionRule matches a signal at or above its min_severity, the advisory pipeline returns Verdict::Deny for that call. The signal is marked promoted = true.
  • Failure of an advisory guard. If an AdvisoryGuard::evaluate itself returns Err, the advisory pipeline propagates it. The wrapping GuardPipeline then maps the error to GuardDenied. Advisory means non-blocking on success, not non-blocking on failure.

When advisory Allow modes apply

The adapter knobs CircuitOpenVerdict::Allow and RateLimitedVerdict::Allow apply to guards whose outputs feed a review queue instead of gating an action. If an external guard is the last line of defense on a capability, leave both at the Deny default.

Example: Err Becomes a Denied Receipt

Example: a guard that reads a journal lock returns an internal error when the lock is poisoned:

rust
impl Guard for SessionVelocityGuard {
    fn name(&self) -> &str {
        "session-velocity"
    }

    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
        let counts = self.journal
            .tool_counts()
            .map_err(|e| KernelError::Internal(
                format!("session-velocity journal error: {e}"),
            ))?;

        match counts.get(&ctx.request.tool_name) {
            Some(n) if *n >= self.limit => Ok(GuardDecision::deny(vec![])),
            _ => Ok(GuardDecision::allow()),
        }
    }
}

When the journal lock is poisoned, the guard returns Err(KernelError::Internal(...)). The kernel's guard loop turns that into:

text
KernelError::GuardDenied(
    "guard \"session-velocity\" error (fail-closed):      session-velocity journal error: poisoned lock"
)

The kernel signs a receipt with verdict Deny and a reason field that contains that message. The agent receives a denial; the audit log records the reason.


Operator Guidance

  • Investigate denial spikes. A spike of fail-closed denials can indicate a guard failure (such as a regex bug or journal corruption) or a rejected agent request. The receipt message identifies the guard and reason.
  • Validate at load time. Move regex compilation, JSON-schema validation, and config parse into the guard's new() path. A configuration error should fail kernel start-up, not generate a denial storm at traffic time.
  • Do not panic in a guard. There is no inline panic boundary to fall back on: in the default path a panic aborts the process (release builds use panic = "abort"). Handle every Option/Result: the workspace denies unwrap/expect on guard and receipt paths for exactly this reason.

Where to Go Next