Advisory Guards
Advisory guards record observations without blocking the request. They sit alongside the deterministic guard pipeline, accumulate signals, and surface them on the receipt. A signal becomes a deny only through an explicit promotion rule. Source: crates/chio-guards/src/advisory.rs.
When to use advisory guards
AdvisorySignal
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 sanitising; advisory guards are responsible for keeping their own metadata schema stable.
AdvisoryGuard trait
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. An advisory guard always allows. A genuine 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.
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. Tool counts and data-flow stats come from SessionJournal::tool_counts and SessionJournal::data_flow. Journal errors (lock poisoning, backing-store failure) surface as 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:
pub struct DataTransferAdvisoryGuard {
journal: Arc<chio_http_session::SessionJournal>,
bytes_threshold: u64,
}Severity escalates with the multiple over threshold:
| Total bytes | Severity |
|---|---|
| < threshold | no signal |
| ≥ 1× and < 2× | Medium |
| ≥ 2× and < 3× | High |
| ≥ 3× | Critical |
PromotionPolicy
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
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.
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<Verdict, 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);
}
}
// 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(Verdict::Deny)
} else {
Ok(Verdict::Allow)
}
}
}Two read accessors sit alongside evaluate:
last_signals()returns every collectedAdvisorySignalfrom the most recent evaluation.last_outputs()wraps the same signals asGuardOutput::Advisoryfor unified export alongside deterministic verdicts.
GuardOutput
Receipt evidence carries a tagged enum so consumers can distinguish a deterministic verdict from an advisory observation:
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GuardOutput {
Deterministic {
guard_name: String,
verdict: bool,
details: Option<String>,
},
Advisory(AdvisorySignal),
}Serialised 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::Internalfrom the offending advisory guard. The pipeline propagates the error, which the kernel converts toKernelError::GuardDeniedper the fail-closed contract. - A poisoned
signalsmutex on the pipeline itself returnsKernelError::Internalwith"advisory pipeline lock poisoned". - An empty pipeline (no advisory guards registered) returns
Verdict::Allowimmediately and produces no signals.
Receipt evidence
Every signal collected during a call lands on 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.
HushSpec snippet
hushspec: "0.1.0"
guards:
advisory:
pipeline:
anomaly:
invocation_threshold: 25
depth_threshold: 6
data_transfer:
bytes_threshold: 10485760 # 10 MiB
promotion:
- guard_name: anomaly-advisory
min_severity: high
- guard_name: data-transfer-advisory
min_severity: criticalThis 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 fight that lock only when calling last_signals() / last_outputs(), which the receipt builder does once after the pipeline returns. Promotion is a linear scan of rules per signal; in practice the rule list is small (under a dozen entries), so this is bounded constant work.
Design notes
- Advisory guards never call out to external services. The async adapter machinery in
chio-guards::externalis 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. - The advisory pipeline is the single point of contact with the deterministic pipeline. Operators register one
AdvisoryPipelineinsideGuardPipeline; the order matters for cost (cheap deterministic checks first) but not for correctness.
Next steps
- Default Pipeline for where the advisory pipeline plugs into the conjunction.
- Fail-Closed Semantics for what a journal-read error does to the verdict.
- Custom Guards to write a deterministic guard once an advisory pattern earns its keep.