Chio/Docs

PlatformGuard Catalog

Advisory Guards

Advisory guards record signals without blocking a call unless a promotion rule returns a deny.

When to use advisory guards

Use this page when: you want to observe anomalies on receipts without blocking, or to roll out a new policy gradually: start in advisory mode and promote it to deny after its false-positive rate is acceptable. Don't use this if: you need a deterministic deny on the first hit; that belongs in the regular guard pipeline.

AdvisorySignal

crates/guards/chio-guards/src/advisory.rs
pub enum AdvisorySeverity {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

pub struct AdvisorySignal {
    pub guard_name: String,
    pub description: String,
    pub severity: AdvisorySeverity,
    pub metadata: Option<serde_json::Value>,
    pub promoted: bool,
}

The five severity levels are ordered: Info < Low < Medium < High < Critical. Promotion rules compare against this ordinal, so a rule with min_severity: medium promotes Medium, High, and Critical signals from the matching guard.

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 without sanitizing; advisory guards are responsible for keeping their own metadata schema stable.


AdvisoryGuard trait

crates/guards/chio-guards/src/advisory.rs
pub trait AdvisoryGuard: Send + Sync {
    fn name(&self) -> &str;
    fn evaluate(&self, ctx: &GuardContext)
        -> Result<Vec<AdvisorySignal>, KernelError>;
}

Distinct from chio_kernel::Guard: the return is a vector of signals, not a verdict. A successful advisory evaluation leaves the decision as Allow unless a promotion rule returns Deny. An internal failure (a backing journal error, for instance) surfaces as KernelError, which the pipeline treats as fail-closed downstream.


AnomalyAdvisoryGuard

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

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

The journal is shared across the kernel; the advisory guard reads but does not mutate. Both AnomalyAdvisoryGuard and DataTransferAdvisoryGuard 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. Emits one signal per call when the total meets or exceeds the configured threshold:

crates/guards/chio-guards/src/advisory.rs
pub struct DataTransferAdvisoryGuard {
    journal: Arc<chio_http_session::SessionJournal>,
    bytes_threshold: u64,
}

Severity escalates with the multiple over threshold:

Total bytesSeverity
< thresholdno signal
≥ 1× and < 2×Medium
≥ 2× and < 3×High
≥ 3×Critical

BehavioralProfileGuard

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

MetricMeaning
call_rateReceipts per window.
deny_rateFraction of denies per window.
unique_toolsDistinct tool names per window.
avg_parameter_entropyShannon entropy of invocation parameters.
ConstantDefaultPurpose
DEFAULT_EMA_ALPHA0.2EMA smoothing factor (~10-sample window).
DEFAULT_SIGMA_THRESHOLD2.0Sigma above which a window is flagged.
DEFAULT_WINDOW_SECS60Rolling window length.
DEFAULT_BASELINE_MIN_WINDOWS3History required before signals start.

Storage is an in-memory Mutex-keyed (agent, metric) baseline map. Receipts are read through a pluggable ReceiptFeedSource trait; production wiring backs it with chio-store-sqlite's ReceiptStore::query_receipts, and InMemoryReceiptFeed serves tests.


PromotionPolicy

crates/guards/chio-guards/src/advisory.rs
pub struct PromotionRule {
    pub guard_name: String,
    pub min_severity: AdvisorySeverity,
}

pub struct PromotionPolicy {
    pub rules: Vec<PromotionRule>,
}

The match is exact on guard_name and ordinal on severity. A signal whose guard name does not match any rule stays advisory. A signal whose severity is below the matched rule's min_severity also stays advisory. A signal at or above triggers promotion: the signal's promoted flag is set, and the pipeline returns Verdict::Deny.

Promotion is per-signal, not per-guard

A guard can emit several signals per call (anomaly counts + delegation depth, for instance). The pipeline checks each signal independently against the rules. One promoted signal in the batch flips the verdict to Deny; the rest stay advisory but are still recorded on the receipt.

AdvisoryPipeline

Wraps a vector of advisory guards plus a promotion policy. It implements chio_kernel::Guard so the standard pipeline can compose it without a second guard registry.

crates/guards/chio-guards/src/advisory.rs
pub struct AdvisoryPipeline {
    guards: Vec<Box<dyn AdvisoryGuard>>,
    policy: PromotionPolicy,
    signals: std::sync::Mutex<Vec<AdvisorySignal>>,    // last-eval cache
}

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".into()
            ))?;
        *stored = collected;

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

The pipeline builds a Vec<GuardEvidence> from every collected signal (via the signal_evidence helper) and attaches it to the returned GuardDecision on both paths. The receipt receives advisory signals from that evidence. The following last_signals() / last_outputs() accessors:

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

GuardOutput

Receipt evidence carries a tagged enum so consumers can distinguish a deterministic verdict from an advisory observation:

crates/guards/chio-guards/src/advisory.rs
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GuardOutput {
    Deterministic {
        guard_name: String,
        verdict: bool,
        details: Option<String>,
    },
    Advisory(AdvisorySignal),
}

Serialised JSON:

json
{
  "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
}

Failure modes

  • A journal read failure during signal emission surfaces as KernelError::Internal from the offending advisory guard. The pipeline propagates the error, which the kernel converts to KernelError::GuardDenied per the fail-closed contract.
  • A poisoned signals mutex on the pipeline itself returns KernelError::Internal with "advisory pipeline lock poisoned".
  • An empty pipeline (no advisory guards registered) returns Verdict::Allow immediately and produces no signals.

Receipt evidence

The pipeline maps each collected signal to the receipt's guard-evidence block via GuardOutput::Advisory. Promoted signals carry promoted: true alongside the full signal payload, so audits can tell which signal flipped the verdict. Non-promoted signals on a denied call are also preserved; the deny is one signal, the rest of the observation context survives.


Wiring

HushSpec does not configure the advisory pipeline. There is no advisory / anomaly / data-transfer / promotion block in the 14 Rules fields, and no config loader wires PromotionPolicy or the journal-backed guards from YAML. Assemble the pipeline in Rust and register it as a single kernel-level guard:

rust
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 out the anomaly detector with promotion at High (a runaway tool loop denies); the data-transfer detector stays observation-only until a Critical (3× the configured 10 MiB) confirms the threshold is set right. Both guards still emit signals on every call where the underlying journal counters cross the line.


Performance class

Each advisory guard runs once per call. The two built-ins are O(1) against the journal (a hashmap lookup and a small struct read). The pipeline locks once per call to swap the last-signal cache. Readers contend for that lock when calling last_signals() / last_outputs(), which the receipt builder does once after the pipeline returns. Promotion scans the rules once per signal. Typical rule lists contain fewer than a dozen entries, so the cost is bounded.


Design notes

  • Advisory guards never call out to external services. The async adapter implementation in chio-guards::external is for deterministic content-safety / threat-intel checks. An observation that needs a network call belongs there, gated on its own breaker.
  • Severity is set by the guard, not by the operator. Operators tune the deny threshold via promotion rules. A guard that wants to be tunable exposes its own knobs (like invocation_threshold) and produces signals at the right severity.
  • Operators register one AdvisoryPipeline inside GuardPipeline. Put inexpensive deterministic checks first to reduce cost; the order does not change correctness.

Next steps