Chio/Docs
LOGIN · JOIN

Advisory Guards

Collect behavioral signals alongside a call and configure the promotion rules that can turn an advisory result into a denial.

Concept chio@5f63e6e

On this page

Advisory guards let an operator watch a behavior on live traffic before refusing it. They record signals on the call's receipt and let the call proceed until a promotion rule names the guard and a severity high enough to deny.

Use one where a deny is the wrong first move: for anomalies you have no rule for yet, and for a new policy run in advisory mode until its false-positive rate is one you would accept as a refusal. A rule that should deny on its first hit belongs in the regular guard pipeline.

A promotion rule turns a matching signal into a deny

A PromotionRule names one guard and a minimum severity. Severity is ordinal, Info < Low < Medium < High < Critical, so a rule with min_severity: medium promotes Medium, High, and Critical signals from the guard it names.

Advisory signals placed by guard and severity, with the promotion region of one rulePromotionRule { guard_name: "anomaly-advisory", min_severity: High }g∗m∗anomaly-advisorydata-transfer-advisoryInfoLowMediumHighCriticalsevinvoked 12×depth 42.4 MBname ≠ g∗m∗
promote(s)  ⟺  name(s)=g∗ ∧ sev(s)≥m∗\text{promote}(s) \iff \mathrm{name}(s) = \htmlClass{role-grant}{g^\ast} \,\wedge\, \mathrm{sev}(s) \ge \htmlClass{role-grant}{m^\ast}
v=Deny  ⟺  ∃ s: promote(s)v = \htmlClass{role-deny}{\text{Deny}} \iff \exists\, s:\ \text{promote}(s)
→ GuardDecision::deny(evidence)1 promoted; all 3 signals recorded
Advisory signals placed by guard and severity, with the promotion region of one rulePromotionRule { guard_name: "anomaly-advisory", min_severity: High }anomaly-advisorydata-transfer-advisoryInfoLowMediumHighCritical×12depth 42.4 MBname ≠ g∗m∗deny(evidence)1 promoted; all 3 signals recorded

Rule: anomaly-advisory at or above High. 1 signal promoted, so the advisory pipeline denies the call. All three signals are recorded as evidence. anomaly-advisory Medium: tool 'search' invoked 12 times (threshold: 10). anomaly-advisory High: delegation depth 4 exceeds threshold 3. data-transfer-advisory High: cumulative data transfer 2,400,000 bytes exceeds threshold 1,000,000 bytes.

promote(s)  ⟺  name(s)=g∗  ⟺   ∧ sev(s)≥m∗v=Deny  ⟺  ∃ s: promote(s)\begin{aligned} \text{promote}(s) &\iff \mathrm{name}(s) = \htmlClass{role-grant}{g^\ast} \\ &\phantom{\iff}\ \wedge\ \mathrm{sev}(s) \ge \htmlClass{role-grant}{m^\ast} \\ v = \htmlClass{role-deny}{\text{Deny}} &\iff \exists\, s:\ \text{promote}(s) \end{aligned}
In Rust · advisory.rs:24617 lines
for guard in &self.guards {
    let signals = guard.evaluate(ctx)?;
    for mut signal in signals {
        if self.policy.should_promote(&signal) {
            signal.promoted = true;
            should_deny = true;
        }
        collected.push(signal);
    }
}

// … store the signals for evidence export
if should_deny {
    Ok(GuardDecision::deny(evidence))
} else {
    Ok(GuardDecision::allow_with_evidence(evidence))
}
Illustrative signals from the two advisory guards in chio-guards for one call, with one promotion rule shown. Names match exactly, every signal is kept as evidence, and the advisory pipeline is not part of the default pipeline.

The name match is exact and the severity match is ordinal. A signal from a guard no rule names, or below the matched rule's min_severity, stays advisory. A signal at or above it is promoted: its promoted flag is set, and the pipeline returns Verdict::Deny.

Promotion is per signal. A guard can emit several signals for one call, such as an invocation count and a delegation depth, and the pipeline checks each against the rules. One promoted signal flips the verdict; the others stay advisory and are still recorded on the receipt.

Three built-in guards measure the session

The two journal-backed guards read the kernel's session journal, and the behavioral profile reads receipts. Each guard sets the severity of what it emits.

GuardReadsEmits whenSeverity
anomaly-advisoryPer-tool invocation countsCount reaches invocation_thresholdMedium; High at 2×
anomaly-advisoryMaximum delegation depthDepth reaches depth_thresholdHigh
data-transfer-advisoryCumulative bytes in plus outTotal reaches bytes_thresholdMedium; High at 2×; Critical at 3×
behavioral-profileReceipts per agent and windowA window crosses the sigma thresholdGuardEvidence; verdict stays Allow

Operators set where the deny happens with promotion rules. A guard that should be tunable exposes its own thresholds, such as invocation_threshold, and emits signals at the right severity.

Every signal reaches the receipt, promoted or not

Receipt evidence carries a tagged GuardOutput enum, so a consumer can tell a deterministic verdict from an advisory observation. An advisory signal serializes like this:

Receipt evidence · GuardOutput::Advisoryjson
{
  "type": "advisory",
  "guard_name": "anomaly-advisory",
  "description": "tool 'read_file' invoked 12 times (threshold: 5)",
  "severity": "high",
  "metadata": { "tool_name": "read_file", "count": 12, "threshold": 5 },
  "promoted": false
}

A promoted signal carries promoted: true with its full payload, so an audit shows which signal flipped the verdict. Signals that were not promoted on a denied call are preserved as well: the deny comes from one signal, and the rest of the observation survives.

The pipeline builds this evidence from every collected signal with the signal_evidence helper and attaches it to the returned GuardDecision on both paths. Attribute signals to a call from that decision. The last_signals() and last_outputs() accessors read shared state that a concurrent evaluation can replace, so they serve as diagnostics.

An advisory guard that fails still refuses the call

Advisory evaluation is non-blocking when it succeeds. When it fails, it follows the kernel's fail-closed contract:

  • A journal read failure during signal emission surfaces as KernelError::Internal from that advisory guard. The pipeline propagates it, and the kernel converts it to KernelError::GuardDenied.
  • A poisoned signals mutex on the pipeline returns KernelError::Internal with "advisory pipeline lock poisoned".
  • An empty pipeline, with no advisory guards registered, returns Verdict::Allow immediately and produces no signals.

Read more in Fail-Closed Semantics.

The whole advisory pipeline registers with the kernel as one guard

Promotion rules and the journal-backed guards are configured in Rust; HushSpec's 14 Rules fields configure the deterministic guards. The embedding host builds the pipeline and hands it to the kernel:

src/main.rs · kernel embeddingrust
let mut policy = PromotionPolicy::new();
policy.add_rule(PromotionRule {
    guard_name: "anomaly-advisory".into(),
    min_severity: AdvisorySeverity::High,
});
policy.add_rule(PromotionRule {
    guard_name: "data-transfer-advisory".into(),
    min_severity: AdvisorySeverity::Critical,
});

let mut advisory = AdvisoryPipeline::new(policy);
advisory.add(Box::new(AnomalyAdvisoryGuard::new(journal.clone(), 25, 6)));
advisory.add(Box::new(DataTransferAdvisoryGuard::new(journal.clone(), 10_485_760)));

kernel.add_guard(Box::new(advisory));

This rolls the anomaly detector out with promotion at High, so a runaway tool loop denies. The data-transfer detector stays observation-only until a Critical signal, three times the configured 10 MiB, confirms the threshold is set right. Both guards still emit signals on every call where their journal counters cross the line.

Register one AdvisoryPipeline inside the GuardPipeline and put inexpensive deterministic checks first to reduce cost. A short-circuit denial before the advisory pipeline skips its evaluation, so choose the order with the observation coverage you want in mind.

Advisory types and guards

AdvisorySignal and AdvisorySeverity

crates/guards/chio-guards/src/advisory.rs45-59rust
pub struct AdvisorySignal {
    /// Name of the advisory guard that produced this signal.
    pub guard_name: String,
    /// Human-readable description of the observation.
    pub description: String,
    /// Severity level.
    pub severity: AdvisorySeverity,
    /// Structured metadata about the observation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata: Option<serde_json::Value>,
    /// Whether this signal has been promoted to a deterministic denial.
    /// This is set by the promotion policy, not by the advisory guard itself.
    #[serde(default)]
    pub promoted: bool,
}
crates/guards/chio-guards/src/advisory.rs30-41rust
pub enum AdvisorySeverity {
    /// Informational observation -- no action needed.
    Info,
    /// Low-severity warning -- worth monitoring.
    Low,
    /// Medium-severity warning -- may warrant investigation.
    Medium,
    /// High-severity warning -- likely needs attention.
    High,
    /// Critical observation -- strong signal of abuse or anomaly.
    Critical,
}

The five severity levels are ordered by declaration, and promotion rules compare against that ordinal. metadata carries structured detail for the receipt and downstream tooling: the tool name, the count and threshold for an anomaly, the byte volume for a transfer guard. The pipeline writes the field through serde as the guard produced it, so each advisory guard keeps its own metadata schema stable.

AdvisoryGuard trait

crates/guards/chio-guards/src/advisory.rs83-92rust
pub trait AdvisoryGuard: Send + Sync {
    /// Human-readable guard name.
    fn name(&self) -> &str;

    /// Evaluate the request and return any advisory signals.
    ///
    /// The returned signals are informational only and do not affect the
    /// request verdict unless a promotion policy is in effect.
    fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError>;
}

The trait is separate from chio_kernel::Guard: it returns a vector of signals instead of a verdict. A successful advisory evaluation leaves the decision as Allow unless a promotion rule returns Deny. An internal failure, such as a backing journal error, surfaces as KernelError, which the pipeline treats as fail-closed downstream.

AnomalyAdvisoryGuard

Reads from a session journal and emits signals on two patterns:

  • A per-tool invocation count meets or exceeds invocation_threshold. Severity escalates to High at 2× the threshold; otherwise it is Medium.
  • The maximum delegation depth meets or exceeds depth_threshold. Severity is High.
crates/guards/chio-guards/src/advisory.rsrust
pub struct AnomalyAdvisoryGuard {
    journal: Arc<chio_http_session::SessionJournal>,
    invocation_threshold: u64,
    depth_threshold: u32,
}

The journal is shared across the kernel, and the advisory guards read it without mutating it. AnomalyAdvisoryGuard and DataTransferAdvisoryGuard each call SessionJournal::snapshot() once per evaluation; the single-lock read returns tool counts and data flow together in a SessionJournalSnapshot. Journal errors, including lock poisoning and backing-store failures, return KernelError::Internal.

DataTransferAdvisoryGuard

Reads cumulative bytes in plus bytes out from the journal's data-flow counters and emits one signal per call when the total meets or exceeds the configured threshold.

crates/guards/chio-guards/src/advisory.rsrust
pub struct DataTransferAdvisoryGuard {
    journal: Arc<chio_http_session::SessionJournal>,
    bytes_threshold: u64,
}
  • Below the threshold: no signal.
  • At 1× and below 2×: Medium.
  • At 2× and below 3×: High.
  • At 3× and above: Critical.

BehavioralProfileGuard

Source crates/guards/chio-guards/src/behavioral_profile.rs, guard name behavioral-profile. It is a synchronous Guard whose verdict is always Allow, and it emits a GuardEvidence entry when a window trips its threshold. It tracks exponentially weighted moving-average baselines per agent and metric over four metrics, and flags a window whose sample crosses the configured sigma threshold against the rolling baseline.

call_rate
Receipts per window.
deny_rate
Fraction of denies per window.
unique_tools
Distinct tool names per window.
avg_parameter_entropy
Shannon entropy of invocation parameters.
ConstantDefaultPurpose
DEFAULT_EMA_ALPHA
0.2
EMA smoothing factor (about a 10-sample window)
DEFAULT_SIGMA_THRESHOLD
2.0
Sigma above which a window is flagged
DEFAULT_WINDOW_SECS
60
Rolling window length
DEFAULT_BASELINE_MIN_WINDOWS
3
History required before signals start

Baselines live in an in-memory, Mutex-guarded map keyed by (agent, metric). Receipts arrive through the pluggable ReceiptFeedSource trait: deployments back it with chio-store-sqlite's ReceiptStore::query_receipts, and InMemoryReceiptFeed serves tests.

PromotionRule and PromotionPolicy

crates/guards/chio-guards/src/advisory.rs103-109rust
pub struct PromotionRule {
    /// Guard name pattern to match (exact match).
    pub guard_name: String,
    /// Minimum severity to promote. Signals at or above this level
    /// from the named guard become deterministic denials.
    pub min_severity: AdvisorySeverity,
}
crates/guards/chio-guards/src/advisory.rs113-116rust
pub struct PromotionPolicy {
    /// Rules for promoting advisory signals to deterministic denials.
    pub rules: Vec<PromotionRule>,
}

The match is exact on guard_name and ordinal on severity. A promoted signal has its promoted flag set by the policy, never by the advisory guard itself.

AdvisoryPipeline

Wraps a vector of advisory guards and a promotion policy. It implements chio_kernel::Guard, so the standard pipeline composes it without a second guard registry, and a promoted signal reaches the caller as an ordinary deny.

crates/guards/chio-guards/src/advisory.rs189-194rust
pub struct AdvisoryPipeline {
    guards: Vec<Box<dyn AdvisoryGuard>>,
    policy: PromotionPolicy,
    /// Collected signals from the last evaluation (for evidence export).
    signals: std::sync::Mutex<Vec<AdvisorySignal>>,
}
crates/guards/chio-guards/src/advisory.rs237-279rust
impl Guard for AdvisoryPipeline {
    fn name(&self) -> &str {
        "advisory-pipeline"
    }

    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
        let mut collected = Vec::new();
        let mut should_deny = false;

        for guard in &self.guards {
            let signals = guard.evaluate(ctx)?;
            for mut signal in signals {
                if self.policy.should_promote(&signal) {
                    signal.promoted = true;
                    should_deny = true;
                }
                collected.push(signal);
            }
        }

        let evidence = collected.iter().map(signal_evidence).collect();

        // Store collected signals for evidence export.
        let mut stored = self
            .signals
            .lock()
            .map_err(|_| KernelError::Internal("advisory pipeline lock poisoned".to_string()))?;
        *stored = collected;

        if should_deny {
            Ok(GuardDecision::deny(evidence))
        } else {
            Ok(GuardDecision::allow_with_evidence(evidence))
        }
    }

    fn revalidate_before_dispatch(&self, _ctx: &GuardContext) -> Result<(), KernelError> {
        // Advisory evaluation reads mutable session state and records evidence.
        // Preserve the admission verdict and evidence instead of evaluating the
        // pipeline a second time at dispatch.
        Ok(())
    }
}

revalidate_before_dispatch keeps the admission verdict and evidence instead of evaluating the pipeline a second time at dispatch, because advisory evaluation reads mutable session state. The diagnostic accessors read the most recent evaluation:

  • last_signals() returns every collected AdvisorySignal from the most recent evaluation.
  • last_outputs() wraps the same signals as GuardOutput::Advisory for unified export beside deterministic verdicts.

GuardOutput

crates/guards/chio-guards/src/advisory.rs64-73rust
pub enum GuardOutput {
    /// A deterministic verdict (allow or deny).
    Deterministic {
        guard_name: String,
        verdict: bool,
        details: Option<String>,
    },
    /// A non-blocking advisory signal.
    Advisory(AdvisorySignal),
}

The enum serializes with #[serde(tag = "type", rename_all = "snake_case")], so an advisory signal carries "type": "advisory" beside its own fields.

Performance

Each advisory guard runs once per call. Both journal-backed guards take a snapshot that clones the retained tool sequence and tool-count map, so snapshot cost grows with that retained state. The pipeline locks once per call to swap the last-signal cache, and readers of last_signals() and last_outputs() contend for that lock. Promotion scans the configured rules per signal; size the rule list and the retained journal for the deployment's evaluation budget.

Custom advisory guards

The built-in advisory guards read local state. A custom advisory implementation is trusted host code and can introduce I/O. The async adapter implementation in chio-guards::external supports external content-safety or threat-intelligence checks with explicit failure and circuit settings; account for provider deadlines and network access when you choose that path.

Next: decide what a failing guard does