Chio/Docs
LOGIN · JOIN

PlatformThe Guard Model

Kernel

Fail-Closed Semantics

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


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.rs387-436rust
fn evaluate_guards_sequential(
    guards: &[Arc<dyn Guard>],
    ctx: &GuardContext,
) -> Result<Vec<chio_core::receipt::metadata::GuardEvidence>, GuardRunError> {
    let mut evidence = Vec::new();
    for guard in guards {
        match guard.evaluate(ctx) {
            Ok(decision) => {
                evidence.extend(decision.evidence);
                match decision.verdict {
                    Verdict::Allow => {
                        debug!(guard = guard.name(), "guard passed");
                    }
                    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; that runs via
                        // `ApprovalGuard::evaluate`. A `Guard` returning `PendingApproval` is an
                        // unsupported state, 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,
                ));
            }
        }
    }
    Ok(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.rs45-54rust
pub enum CryptoFloor {
    /// Accept classical-only envelopes (no post-quantum key required). Default.
    AllowClassical,
    /// Accept either classical-only or hybrid classical-plus-ML-DSA-65
    /// envelopes. Requires a PQ key to be provisioned.
    AllowHybrid,
    /// Reject classical-only envelopes; require hybrid signing on every
    /// signed artifact. Requires a PQ key to be provisioned.
    PqRequired,
}

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.
WASM trapDenyThe module traps; the WASM-host guard returns an error.The guard module is marked unhealthy and stays quarantined until a reload replaces it.
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 the callout below.
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.
Permanent error (4xx, malformed request)DenyExternalGuardError::Permanent short-circuits the retry loop.No retry, and not configurable. A misconfigured provider credential denies immediately rather than burning the retry budget.
Journal unavailable (session-aware guard)DenyGuard returns KernelError::Internal.tracing event with guard name; receipt records the deny.
Receipt store write failureDenyThe kernel cannot record evidence for the call.An unrecordable call is denied rather than allowed unrecorded. Receipt totality is the stronger invariant.
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.

Reason classification is narrower than this matrix. Only WASM guard denials carry a bounded reason_class label, on the chio_guard_deny_total{guard_id, reason_class} counter: chio-wasm-guards is the sole producer and the kernel /metrics endpoint only renders it. The label domain is fixed to nine values: policy, pii, secret, prompt_injection, oversize, fuel, trap, malformed, other, assigned by substring-matching the guard's free-form deny reason, with any unrecognized reason folded into other. Native-guard, circuit-breaker, mutex-poisoning, session-journal, and receipt-store denials in the table above carry no reason_class.

ResponseSanitizationGuard custom-pattern exception

For most guards a malformed regex is a construction-time error, not a runtime hole: BrowserAutomationGuard, MemoryGovernanceGuard, and ContentReviewGuard each return an InvalidPattern error when a configured pattern fails to compile, so the guard never builds and kernel start-up fails. The exception is ResponseSanitizationGuard: an invalid custom pattern (compiled through build_pattern) is silently dropped, leaving that single pattern unenforced. Validate the custom-pattern list before deployment.

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. Both knobs are specified on External Guard Adapters.

Example: an 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