Advisory Guards
Collect behavioral signals alongside a call and configure the promotion rules that can turn an advisory result into a denial.
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.
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.
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))
}
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.
| Guard | Reads | Emits when | Severity |
|---|---|---|---|
anomaly-advisory | Per-tool invocation counts | Count reaches invocation_threshold | Medium; High at 2× |
anomaly-advisory | Maximum delegation depth | Depth reaches depth_threshold | High |
data-transfer-advisory | Cumulative bytes in plus out | Total reaches bytes_threshold | Medium; High at 2×; Critical at 3× |
behavioral-profile | Receipts per agent and window | A window crosses the sigma threshold | GuardEvidence; 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:
{
"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::Internalfrom that advisory guard. The pipeline propagates it, and the kernel converts it toKernelError::GuardDenied. - A poisoned
signalsmutex on the pipeline returnsKernelError::Internalwith"advisory pipeline lock poisoned". - An empty pipeline, with no advisory guards registered, returns
Verdict::Allowimmediately 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:
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
Try promoted, invocation_threshold, or DEFAULT_SIGMA_THRESHOLD.
AdvisorySignal and AdvisorySeverity
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,
}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
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.
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.
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.
| Constant | Default | Purpose |
|---|---|---|
DEFAULT_ | 0.2 | EMA smoothing factor (about a 10-sample window) |
DEFAULT_ | 2.0 | Sigma above which a window is flagged |
DEFAULT_ | 60 | Rolling window length |
DEFAULT_ | 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
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,
}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.
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>>,
}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 collectedAdvisorySignalfrom the most recent evaluation.last_outputs()wraps the same signals asGuardOutput::Advisoryfor unified export beside deterministic verdicts.
GuardOutput
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.