PlatformGuard Catalog
Advisory Guards
Advisory guards record signals without blocking a call unless a promotion rule returns a deny.
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 sanitizing; 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. 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.
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:
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 |
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.
| Metric | Meaning |
|---|---|
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_EMA_ALPHA | 0.2 | EMA smoothing factor (~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. |
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
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<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 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
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:
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::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. - Operators register one
AdvisoryPipelineinsideGuardPipeline. Put inexpensive deterministic checks first to reduce cost; the order does not change 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.