Chio/Docs

Fail-Closed Semantics

The invariant: when in doubt, deny. A guard that crashes, times out, loses a lock, or hits a parse error denies the call. The kernel signs the deny receipt the same way it would sign an allow. There is no fallback path that bypasses guards on failure.

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
Trust ring stack. The chio core sits at the smallest TCB; outer rings carry broader trust assumptions documented in `formal/assumptions.toml`.

The Invariant

The synchronous GuardPipeline encodes the invariant in one short match:

crates/chio-guards/src/pipeline.rs
match guard.evaluate(ctx) {
    Ok(Verdict::Allow) => continue,
    Ok(Verdict::PendingApproval) => { /* sticky, keep iterating */ }
    Ok(Verdict::Deny) => {
        return Err(KernelError::GuardDenied(format!(
            "guard \"{}\" denied the request",
            guard.name()
        )));
    }
    Err(e) => {
        // Fail closed: guard errors are treated as denials.
        return Err(KernelError::GuardDenied(format!(
            "guard \"{}\" error (fail-closed): {e}",
            guard.name()
        )));
    }
}

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 pipeline never silently downgrades to Allow on failure. There is no best-effort mode for the synchronous catalog.

Failure-Mode Matrix

Every failure class collapses onto one of two outcomes. Either the pipeline turns it into a deny, or the wrapping kernel does.

FailureVerdictReasonSide Effects
Panic in evaluateDenyKernel catches the unwind and converts to GuardDenied.Receipt records guard name; tracing logs the panic payload.
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.
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.

Why the Kernel Catches Panics

A panic inside a single guard should not unwind the kernel process. The kernel wraps guard evaluation in a panic-catching boundary and converts the unwind into KernelError::GuardDenied. That preserves three invariants at once:

  • The kernel keeps serving other requests after a buggy guard panics.
  • The receipt for the panicked request is still signed and persisted, so the audit log is gap-free.
  • The agent receives a clean error, not a silently dropped call or a hung connection.

Operators should still treat a panic as a bug. The tracing event carries the panic payload, the guard name, and the request id, which is enough to reproduce.


The Advisory Exception

The AdvisoryPipeline does not deny on success. That is the whole point of advisory mode: signals are recorded, the request proceeds. There are two narrow carve-outs:

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

Advisory Allow modes are deliberate

The adapter knobs CircuitOpenVerdict::Allow and RateLimitedVerdict::Allow exist for guards whose outputs feed a review queue rather than gate 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

Concrete walk-through. A guard that needs to read 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<Verdict, 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(Verdict::Deny),
            _ => Ok(Verdict::Allow),
        }
    }
}

When the journal lock is poisoned, the guard returns Err(KernelError::Internal(...)). The pipeline 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 has a gap-free record of why.


Operator Guidance

  • Treat denial spikes as both signal and noise. A spike of fail-closed denials may mean a guard is misbehaving (regex bug, journal corruption) rather than the agent attempting something forbidden. The receipt message tells you which.
  • 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 catch panics inside a guard. Let panics bubble. The kernel's panic boundary turns them into fail-closed denies and emits the right tracing event. Catching inside the guard hides the bug.

Where to Go Next