Chio/Docs
LOGIN · JOIN

Guards Spec

Find the exact rule a guard follows: its category, fail-closed obligations, verdict, evidence, WASM limits, and session journal reads.

Reference chio@5f63e6e

On this page

Guards decide whether a call routed through the kernel reaches its tool. Each guard returns a verdict and evidence, and the kernel signs that evidence into the call’s receipt. A denial or error from a pre-invocation guard stops the call before dispatch; a post-invocation hook shapes the response the agent receives.

The rules below are spec/GUARDS.md, version 1.0, set beside the crates that implement them. For what guards are for and how the catalog is organized, read Guards.

Synopsis

A guard is a Rust type that implements Guard. It names itself and evaluates one GuardContext into a GuardDecision; the trait gives every other method a default body.

Guard trait · method signaturesrust
pub trait Guard: Send + Sync {
    fn name(&self) -> &str;
    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError>;

    // Provided methods: the source gives each a default body.
    fn requires_dispatch_revalidation(&self) -> bool;
    fn revalidate_required_before_dispatch(&self, ctx: &GuardContext) -> Result<(), KernelError>;
    fn revalidate_before_dispatch(&self, _ctx: &GuardContext) -> Result<(), KernelError>;
    fn required_finding_status_feed_id(
        &self,
        _ctx: &GuardContext,
    ) -> Result<Option<String>, KernelError>;
    fn validate_output_before_release(
        &self,
        _ctx: &GuardContext,
        _output: &ToolServerOutput,
    ) -> Result<(), KernelError>;
    fn requires_exact_released_output(&self, _ctx: &GuardContext) -> bool;
}

Guard requires Send + Sync. An Err from evaluate is a denial, so a guard that cannot decide refuses.

Guard pipeline

The Chio runtime kernel evaluates guards in a sequential pipeline before admitting an invocation. Each guard returns a GuardDecision that carries a verdict and zero or more GuardEvidence entries. The kernel attaches the entries to the signed receipt for the invocation, so the receipt records which guards evaluated the request and what they observed.

Guard categories

The spec classifies guards by state requirement and execution phase (section 1.1).

CategoryStatePhaseBlocking
Stateless deterministic
None
Pre-invocation
Yes
Session-aware deterministic
Session journal
Pre-invocation
Yes
Post-invocation hooks
Tool response
Post-invocation
Yes
Advisory signals
Session journal (optional)
Pre-invocation
No (unless promoted)
WASM custom guards
Sandboxed runtime
Pre-invocation
Configurable

The chio-data-guards crate is a sibling of chio-guards that implements the same Guard trait for SQL, vector-store, and warehouse-cost checks, plus a post-invocation hook for query results. Its guards sit outside the taxonomy and answer to the runtime names query-result, sql-query, vector-db, and warehouse-cost.

Evaluation order

Guards SHOULD be evaluated in this order within the pipeline (section 1.2):

  1. Stateless deterministic guards (cheapest, no I/O).
  2. Session-aware deterministic guards (require a journal read).
  3. WASM custom guards (sandboxed execution, potentially expensive).
  4. Advisory pipeline (non-blocking signals, evaluated last for observability).

Post-invocation hooks run after the tool produces a response and before delivery to the agent.

GuardPipeline evaluates its guards in registration order and is itself a Guard named guard-pipeline, so a pipeline registers on the kernel as one guard through kernel.add_guard(Box::new(pipeline)). The order the spec recommends holds when the operator adds guards in that order.

Fail-closed rules

The guard pipeline as a conjunction of guard resultsr =read_file README.mdread_file ~/.ssh/id_rsahttp_get evil.examplegj(r)j = 0forbidden-path0j = 1shell-commandj = 2egress-allowlistj = 3path-allowlistj = 4mcp-toolj = 5secret-leakj = 6patch-integrity∧no dispatch
v(r)=⋀j=06 gj(r)v(r) = \displaystyle\htmlClass{role-seal}{\bigwedge_{j=0}^{6}}\, \htmlClass{role-call}{g_j}(r)
v(r)=0∧⋯=0\phantom{v(r)}= \htmlClass{role-deny}{0} \wedge \cdots = \htmlClass{role-deny}{0}
gj(r)={1Allow0Deny or error\htmlClass{role-call}{g_j}(r) = \begin{cases} 1 & \text{Allow} \\ \htmlClass{role-deny}{0} & \text{Deny or error} \end{cases}
first gj = 0 at j = 0; later guards do not run
The guard pipeline as a conjunction of guard resultsr =read_file README.mdread_file ~/.ssh/id_rsahttp_get evil.examplegj(r)forbidden-path0shell-commandegress-allowlistpath-allowlistmcp-toolsecret-leakpatch-integrity∧v = 0no dispatchfirst gj = 0 at j = 0; later guards do not run

forbidden-path denies the request; the pipeline stops and the request is not dispatched.

v(r)=⋀j=06 gj(r)=0∧⋯=0gj(r)={1Allow0Deny or error\begin{aligned} v(r) &= \htmlClass{role-seal}{\bigwedge_{j=0}^{6}}\, \htmlClass{role-call}{g_j}(r) \\ &= \htmlClass{role-deny}{0} \wedge \cdots = \htmlClass{role-deny}{0} \\[4pt] \htmlClass{role-call}{g_j}(r) &= \begin{cases} 1 & \text{Allow} \\ \htmlClass{role-deny}{0} & \text{Deny or error} \end{cases} \end{aligned}
In Rust · pipeline.rs:6620 lines
for guard in &self.guards {
    match guard.evaluate(ctx) {
        Ok(decision) => {
            evidence.extend(decision.evidence);
            match decision.verdict {
                Verdict::Allow => continue,
                // … PendingApproval
                Verdict::Deny => {
                    // … record the denial
                    return Ok(GuardDecision::deny(evidence));
                }
            }
        }
        Err(e) => {
            // Fail closed: guard errors are treated as denials.
            // … record the error
            return Ok(GuardDecision::deny(evidence));
        }
    }
}
The seven guards of the default pipeline, in registration order, with illustrative requests. A request reaches the pipeline only after its capability check passes. A guard error counts as a denial.Guards

The pipeline operates under one fail-closed invariant (section 1):

  • If any guard returns Deny, the pipeline MUST short-circuit and deny the request.
  • If any guard returns an error (including internal panics, lock poisoning, or serialization failures), the pipeline MUST treat the request as denied.
  • Only when every guard in the pipeline returns Allow is the request admitted.

GuardPipeline::evaluate implements the invariant. A Deny appends an evidence entry with verdict: false and the details action=deny; reason=guard denied request, then returns. An Err appends action=error; reason=fail-closed; error=... and returns a Deny. Guards after the first Deny or Err never run and leave no evidence.

Per-guard fail-closed obligations are listed under each guard. Fail-closed evaluation covers the kernel side.

Verdicts

GUARDS.md names Allow and Deny. The kernel verdict type adds a third variant, PendingApproval:

crates/kernel/chio-kernel/src/runtime.rs30-38rust
pub enum Verdict {
    /// The action is allowed.
    Allow,
    /// The action is denied.
    Deny,
    /// The action is suspended pending a human decision. Look up the
    /// associated `ApprovalRequest` via the HITL API.
    PendingApproval,
}
  • Allow. The pipeline continues to the next guard. When every guard allows, the pipeline returns Allow with the collected evidence.
  • Deny. The pipeline returns at once with the evidence collected so far plus its own deny entry.
  • PendingApproval. Sticky. The pipeline keeps evaluating the remaining guards so a later guard can still deny, and returns PendingApproval when none does. The ApprovalRequest travels separately through the human-in-the-loop API.

For the portable core, From<Verdict> for chio_kernel_core::GuardStep maps PendingApproval to GuardStep::Error.

Evidence

GuardEvidence carries guard_name, verdict (a boolean, true for pass), and an optional details string that serde omits when absent. A guard returns its entries inside GuardDecision; the pipeline concatenates them in evaluation order and adds its own entry on a deny or an error.

GuardOutput

GuardOutput gives deterministic results and advisory signals one shape in the evidence array. Serde tags it with a type field in snake_case.

VariantTagFieldsDescription
Deterministic
"deterministic"
guard_name, verdict (bool), details
Result from a standard guard
Advisory
"advisory"
All AdvisorySignal fields
Non-blocking observation

Stateless deterministic guards

Stateless deterministic guards inspect the current request context only: tool name, arguments, agent identity, and capability scope. They need no external state, no session history, and no I/O, so the same inputs reproduce the same verdict.

InternalNetworkGuard

Guard name: internal-network. Blocks network egress to private, reserved, and cloud infrastructure addresses, which closes the server-side request forgery (SSRF) path through tool invocations on the HTTP transport. Blocked address classes (section 2.1):

ClassRangeRationale
RFC 1918 (Class A)
10.0.0.0/8
Private networks
RFC 1918 (Class B)
172.16.0.0/12
Private networks
RFC 1918 (Class C)
192.168.0.0/16
Private networks
Loopback (IPv4)
127.0.0.0/8
Host-local services
Loopback (IPv6)
::1
Host-local services
Link-local (IPv4)
169.254.0.0/16
Auto-configured addresses
Link-local (IPv6)
fe80::/10
Neighbor discovery scope
Unique local (IPv6)
fc00::/7
Private IPv6 ranges
Cloud metadata
169.254.169.254, metadata.google.internal, metadata.azure.com
Cloud instance credentials
Kubernetes
kubernetes.default.svc, kubernetes.default
Cluster-internal APIs
Broadcast
255.255.255.255
Network broadcast
Current network
0.0.0.0/8
Ambiguous origin
IPv4-mapped IPv6
::ffff:<private-v4>
Bypass via address format

The guard also blocks the IPv6 unspecified address ::, and treats instance-data and any hostname ending in .internal as cloud metadata hosts.

DNS rebinding detection, on by default, blocks hostnames that embed a private IP with dash or dot separators (for example evil.127-0-0-1.attacker.com). Encoded IP detection blocks hostnames that look like a hexadecimal, decimal, or octal IP. The guard matches hostnames without resolving them, so a hostname that matches none of these checks passes.

OptionTypeDefaultDescription
extra_blocked_hosts
string[]
[]
Additional hostnames to block beyond the built-in set
dns_rebinding_detection
bool
true
Enable the dash and dot embedded-IP heuristic

On denial, the evidence carries the blocked host and the classification reason (section 2.1). check_host returns one of these reasons:

  • cloud metadata endpoint: <host> for a metadata IP or hostname.
  • blocked host: <host> for a host in extra_blocked_hosts.
  • DNS rebinding suspect: <host> for the rebinding heuristic, when enabled.
  • private/reserved IP: <ip> for a literal IP in a blocked range.
  • encoded IP pattern in hostname: <host> for a hostname that looks like an encoded IP.

Any IP parse error, ambiguous address, or unexpected format MUST result in denial (section 2.1), and a request whose action the host cannot extract denies before any host check. Requests that do not involve network egress (file reads, shell commands) MUST pass through this guard without evaluation: the guard activates only for NetworkEgress actions.

AgentVelocityGuard

Guard name: agent-velocity. Enforces per-agent and per-session rate limits with token buckets. The grant-scoped VelocityGuard limits one capability; this guard limits an agent identity across every capability it holds. Bucket arithmetic uses integer milli-tokens, so there is no floating-point drift.

BucketKeyPurpose
Per-agent
agent_id
Cross-capability rate limit for one agent
Per-session
(agent_id, capability_id)
Rate limit within one capability context

When both limits are configured, both MUST pass. The guard peeks both buckets before consuming from either, so a denied request burns no token. A poisoned state lock returns KernelError::Internal, which the pipeline treats as a denial. The guard also bounds how many buckets it keeps: a request that would create a bucket beyond bucket_cap denies. On denial, the evidence names the limit that was exceeded, per-agent or per-session, and the current token count (section 2.2).

OptionTypeDefaultDescription
max_requests_per_agent
u32 or null
null (unlimited)
Maximum requests per agent per window
max_requests_per_session
u32 or null
null (unlimited)
Maximum requests per session per window
window_secs
u64
60
Window duration in seconds
burst_factor
f64
1.0
Bucket capacity as a multiple of the per-window limit; 1.0 allows no burst above the steady rate

These options and their defaults are AgentVelocityConfig.

Session-aware deterministic guards

Session-aware deterministic guards read the session journal and decide from cumulative session history. Each holds an Arc<SessionJournal> and produces the same verdict for the same journal state. DataFlowGuard and BehavioralSequenceGuard sit outside GuardPipeline::default_pipeline(); an operator registers them with a journal the runtime caller supplies (section 3).

DataFlowGuard

Guard name: data-flow. Enforces cumulative data transfer limits per session, so many small requests cannot move a large volume in aggregate. The guard reads the journal’s CumulativeDataFlow from one snapshot() and compares running totals with the limits; a total at or above a limit denies.

OptionTypeDefaultDescription
max_bytes_read
u64 or null
null (unlimited)
Maximum cumulative bytes read per session
max_bytes_written
u64 or null
null (unlimited)
Maximum cumulative bytes written per session
max_bytes_total
u64 or null
null (unlimited)
Maximum cumulative bytes (read + written) per session

These options are DataFlowConfig. If the session journal is unavailable (lock poisoned, I/O error), the guard MUST return an error, which the pipeline treats as a denial, and MUST NOT default to allow. The guard maps the journal error to KernelError::Internal.

BehavioralSequenceGuard

Guard name: behavioral-sequence. Enforces tool ordering policies. The guard reads one journal snapshot and checks the constraints of its SequencePolicy:

  • Required predecessors. Tool X MUST NOT run unless tools Y and Z have been invoked earlier in the session.
  • Forbidden transitions. Tool X MUST NOT run immediately after tool Y.
  • Max consecutive. The same tool MUST NOT run more than N times consecutively.
  • Required first tool. The first tool in a session MUST match a specified name.
OptionTypeDefaultDescription
required_predecessors
HashMap<String, HashSet<String>>
empty
Tool name to the set of tools that must have run before it
forbidden_transitions
Vec<(String, String)>
empty
(from_tool, to_tool) pairs that are forbidden
max_consecutive
Option<u32>
None (unlimited)
Maximum consecutive invocations of the same tool
required_first_tool
Option<String>
None
Tool that must be the first invocation in a session

Two checks read cumulative journal fields that survive ring eviction. The required-first-tool check reads current_streak_tool, which is unset only before the first record, and the required-predecessor check reads the cumulative tool_counts. The max-consecutive check reads the journal’s streak counter. A journal error returns KernelError::Internal, which the pipeline treats as a denial.

Post-invocation hooks

Post-invocation hooks run after a tool produces a response and before the response reaches the agent. A hook inspects the response and can modify, block, or escalate it. The tool has already run, so a hook shapes what the agent receives and leaves the tool’s effect in place.

PostInvocationPipeline

PostInvocationPipeline is defined in chio-kernel and evaluates hooks in registration order. Each hook returns one PostInvocationVerdict:

  • Allow. The response passes through unmodified.
  • Block(reason). The response is replaced with an error message, and the pipeline short-circuits.
  • Redact(value). The response content is replaced with the redacted version, and subsequent hooks see the redacted version.
  • Escalate(message). The response is delivered, and an escalation signal is emitted for operator review.

Pipeline semantics (section 4.1; evaluate_with_context_and_evidence):

  • A Block from any hook MUST stop the pipeline immediately. No subsequent hooks run; the outcome carries the evidence collected so far.
  • A Redact replaces the response for all subsequent hooks. Multiple Redact hooks compose sequentially.
  • Escalate messages are collected throughout the pipeline and joined with ; in the final verdict.
  • The final verdict is Redact when the response changed, Escalate when any hook escalated, and otherwise Allow.

On the durable admission path, hooks are non-blocking by contract: a Block there is an error.

ResponseSanitizationGuard

Guard name: response-sanitization. Scans tool responses, and request arguments when used pre-invocation, for PII and PHI patterns, then blocks or redacts matches before the data reaches the agent. The built-in patterns come from default_patterns(); the first column below is each pattern’s name field.

PatternExampleSensitivityRedaction
SSN
123-45-6789
High
[SSN REDACTED]
email
user@example.com
Medium
[EMAIL REDACTED]
phone
(555) 123-4567
Low
[PHONE REDACTED]
credit-card
4111-1111-1111-1111
High
[CARD REDACTED]
date-of-birth
1990-01-15 or 01/15/1990
Low
[DATE REDACTED]
MRN
MRN: 123456789
High
[MRN REDACTED]
ICD-10
J18.9, E11
Medium
[ICD REDACTED]

Sensitivity levels (SensitivityLevel):

  • Low. May produce false positives (phone numbers, dates).
  • Medium. Likely PII (emails, medical codes).
  • High. Definite PII or PHI (SSN, credit card, MRN).

min_level sets the minimum sensitivity; the guard skips every pattern below it when it scans and when it redacts. The action on a match is a SanitizationAction: Block denies the response entirely if any pattern matches, and Redact replaces matching patterns with their redaction strings and allows the response. Configuration (section 4.2):

OptionTypeDefaultDescription
min_level
SensitivityLevel
Low
Minimum sensitivity level to trigger
action
SanitizationAction
Block
Action to take on a match
custom_patterns
SensitivePattern[]
[]
Additional regex patterns to scan for

In Rust, ResponseSanitizationGuard::new(min_level, action) takes both values explicitly, and additional patterns come through with_additional_patterns.

Operators MAY define custom patterns through build_pattern(name, regex, level, redaction), which returns None for an invalid regex. A built-in pattern that fails to compile replaces the whole set with one catch-all pattern named redaction_unavailable_fail_closed that matches every response. The guard runs in two phases:

  • Pre-invocation (Guard trait). Scans request arguments for PII that should not reach tool servers and denies if patterns are found.
  • Post-invocation. scan_response returns ScanResult::Clean, ScanResult::Blocked(findings), or ScanResult::Redacted { redacted_text, redaction_count, findings }.

Advisory pipeline and promotion

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.Advisory guards

Advisory signals are non-blocking observations emitted during guard evaluation. They give operators visibility into request patterns and leave the verdict unchanged unless a promotion rule promotes them.

AdvisorySignal

FieldTypeDescription
guard_name
string
Name of the advisory guard that produced the signal
description
string
Human-readable observation
severity
AdvisorySeverity
Severity classification
metadata
object or absent
Structured metadata about the observation; serde omits it when absent
promoted
boolean
Whether the promotion policy promoted this signal to a denial; the guard sets it false

Signals serialize into the receipt as GuardOutput entries and MUST be included in the signed receipt body so that auditors can review every observation (section 5.1). The pipeline’s evidence entry for a signal has verdict: !promoted and the details action=advisory; description=...; severity=...; promoted=...; metadata=....

Severity levels

LevelOrdinalMeaning
Info
0
Informational observation, no action needed
Low
1
Worth monitoring over time
Medium
2
May warrant investigation
High
3
Likely needs operator attention
Critical
4
Strong signal of abuse or anomaly

AdvisorySeverity serializes in snake_case, and severity_ord supplies the ordinal. A promotion rule with min_severity: Medium promotes signals at Medium, High, and Critical.

AdvisoryPipeline behavior

The AdvisoryPipeline wraps AdvisoryGuard implementations and a PromotionPolicy. It implements the kernel’s Guard trait, so it registers in the standard pipeline under the name advisory-pipeline. Its evaluate runs these steps:

  1. Evaluates every registered advisory guard in order. A guard error propagates as the pipeline’s error, which the outer pipeline treats as a denial.
  2. Each guard returns zero or more AdvisorySignal entries.
  3. For each signal, the pipeline checks the promotion policy.
  4. If any signal matches a promotion rule, it is marked promoted: true and the pipeline returns Deny.
  5. If no signal is promoted, the pipeline returns Allow with the signal evidence.
  6. All collected signals, promoted or not, are stored for evidence export through last_signals() and last_outputs().

Without any promotion rule, the advisory pipeline MUST always return Allow (section 5.3). At dispatch it keeps the admission verdict and evidence and evaluates no second time.

PromotionPolicy

Operators configure promotion rules in chio.yaml to convert advisory signals into deterministic denials (section 5.4). The global guards.allow_advisory_promotion switch gates whether any rule applies.

spec/GUARDS.mdyaml
advisory:
  promotion_rules:
    - guard_name: anomaly-advisory
      min_severity: high
    - guard_name: data-transfer-advisory
      min_severity: critical
PromotionRule fieldTypeDescription
guard_name
string
Exact match on the advisory guard’s name
min_severity
AdvisorySeverity
Minimum severity to promote

A signal matches a rule when the guard name is equal and the signal severity is at least the rule severity (should_promote); the pipeline then sets promoted to true and returns Deny. In Rust, a policy is built from PromotionRule values through PromotionPolicy::add_rule and passed to AdvisoryPipeline::new. Both types derive serde: a serialized policy is a rules list of the same entries, with snake_case severity values.

Built-in advisory guards

AnomalyAdvisoryGuard (anomaly-advisory) flags unusual invocation patterns and excessive delegation depth. It reads one journal snapshot.

ConditionSeverityDescription
Tool invoked >= threshold times
Medium
Tool X invoked N times (threshold: T); metadata tool_name, count, and threshold
Tool invoked >= 2x threshold
High
Elevated severity for sustained repetition
Delegation depth >= threshold
High
Delegation depth D exceeds threshold T
OptionTypeDefaultDescription
invocation_threshold
u64
(required)
Per-tool invocation count that triggers a signal
depth_threshold
u32
(required)
Delegation depth that triggers a signal

DataTransferAdvisoryGuard (data-transfer-advisory) flags sessions with high cumulative data transfer, as an early warning before the deterministic DataFlowGuard limit is reached. It takes one bytes_threshold and reads one journal snapshot.

ConditionSeverityDescription
Total bytes >= threshold
Medium
Cumulative transfer at or above the threshold
Total bytes >= 2x threshold
High
Elevated severity
Total bytes >= 3x threshold
Critical
Critical data volume

Signals carry total_bytes, bytes_read, bytes_written, and threshold in metadata. Advisory guards shows the promotion path inside the kernel.

WASM custom guards

The chio-wasm-guards crate lets an operator write a guard in any language that compiles to WebAssembly and load it into the kernel at runtime. The runtime accepts a raw core module that exports evaluate and a Component Model component that implements the guard world in wit/chio-guard/world.wit, and tells the two apart from the bytes (detect_wasm_format).

Host-guest ABI

Each raw core-module guard MUST export (section 6.1):

spec/GUARDS.mdrust
evaluate(request_ptr: i32, request_len: i32) -> i32

The core-module invocation protocol (section 6.1) runs in four steps:

  1. The host serializes a GuardRequest as JSON.
  2. If the guest exports chio_alloc(size: i32) -> i32, the host requests a buffer and checks that the returned range lies inside guest memory; a trapped, negative, or out-of-bounds allocator is a guard error. Without the export, the host writes at offset 0.
  3. The host writes the JSON bytes at the selected offset and calls evaluate(request_offset, json_length).
  4. The guest reads the request, evaluates it, and returns a verdict code.
  • 0. Allow (VERDICT_ALLOW).
  • 1. Deny (VERDICT_DENY).
  • Any other i32. Error. The backend reports it as a trap, unexpected return value from evaluate, which a blocking guard turns into a denial.

A denying core module MAY export chio_deny_reason(buffer_ptr: i32, buffer_len: i32) -> i32. The host supplies a 4096-byte region at offset 65536, parses the returned bytes as a JSON GuestDenyResponse, and accepts plain UTF-8 as the reason when they are not JSON, trimming trailing NULs and dropping an empty string. Without the export, the host reads a NUL-terminated UTF-8 reason from offset 65536, at most 4096 bytes. An absent, empty, malformed, or failed reason keeps the deny verdict and omits the reason.

chio guard inspect reads a compiled module's exports and checks them against this ABI before a host loads it:

Terminalbash
chio guard inspect target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
Output
=== WASM Guard Inspection ===

File: target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
Size: 189.6 KiB

Exported functions:
  memory           (memory)
  evaluate         (function)
  chio_alloc       (function)
  chio_deny_reason (function)
  chio_free        (function)
  __data_end       (global)
  __heap_base      (global)

ABI compatibility: COMPATIBLE
  [+] evaluate
  [+] chio_alloc
  [+] chio_deny_reason

Memory:
  initial=17 pages (1088 KiB), max=unbounded pages

GuardRequest

The JSON payload written into guest memory. Serde omits an absent optional field and an empty filesystem_roots.

FieldTypeDescription
tool_name
string
Tool being invoked
server_id
string
Server hosting the tool
agent_id
string
Agent making the request
arguments
object
Tool arguments (opaque JSON)
scopes
string[]
Granted scope names, formatted as "server_id:tool_name"
action_type
string or absent
Host-extracted action class
extracted_path
string or absent
Normalized filesystem path for filesystem actions
extracted_target
string or absent
Target domain for network egress actions
filesystem_roots
string[] or absent
Session-scoped filesystem roots
matched_grant_index
integer or absent
Index of the capability grant the kernel selected

action_type is one of file_access, file_write, network_egress, shell_command, mcp_tool, patch, or unknown. When host-side action extraction fails, the guard denies before the guest runs, under the action type malformed_arguments.

Fuel metering

WASM guards execute under a fuel budget that bounds instruction count. The runtime charges fuel per instruction and stops the guest when the budget is spent. DEFAULT_FUEL_LIMIT is 10,000,000 units per invocation, and chio-config applies the same default to a chio.yaml entry. After each call the backend records the fuel consumed and classifies a failed call: a message that mentions fuel becomes WasmGuardError::FuelExhausted { consumed, limit }, and anything else becomes WasmGuardError::Trap.

When fuel runs out, the runtime MUST terminate the guest and return WasmGuardError::FuelExhausted; a blocking guard treats the error as a denial, and an explicitly advisory guard records it and stays non-blocking (section 6.3). Any WASM trap (memory access violation, stack overflow, unreachable instruction) MUST result in denial for a blocking guard. WasmGuard::evaluate turns any backend error into Deny for a blocking guard and Allow for an advisory one, and a guest Deny from an advisory guard also becomes Allow.

If the module does not export the required evaluate function or memory, the load MUST fail and the guard MUST NOT be registered in the pipeline (section 6.3), and the backend reports WasmGuardError::MissingExport. load_module also checks the size and the imports and pre-instantiates the module.

chio guard bench measures what one evaluation costs against a fuel limit before you set the limit in chio.yaml. A deterministic guest consumes the same fuel on every iteration:

Terminalbash
chio guard bench target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
  --iterations 100 --fuel-limit 1000000
Output
=== Guard Benchmark ===

File: target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm
Iterations: 100
Fuel limit: 1,000,000

Latency:
  p50:  336.40 us
  p99:  31294.91 us
  min:  269.84 us
  max:  31294.91 us
  mean: 1219.19 us

Fuel consumed:
  p50:  20,272
  p99:  20,272
  min:  20,272
  max:  20,272
  mean: 20,272

WASM guard configuration

WASM guards are declared in chio.yaml under wasm_guards (section 6.4):

spec/GUARDS.mdyaml
wasm_guards:
  - name: custom-pii-guard
    path: /etc/chio/guards/pii_guard.wasm
    fuel_limit: 5000000
    priority: 100
    advisory: false

The options and their defaults are WasmGuardConfig:

OptionTypeDefaultDescription
name
string
(required)
Human-readable name, used in receipts and logs
path
string
(required)
Filesystem path to the .wasm module
fuel_limit
u64
10,000,000
Maximum fuel units per invocation
priority
u32
1000
Evaluation order; lower values run first
advisory
bool
false
If true, a denial or error is logged and not enforced
max_memory_bytes
usize
16 MiB
Maximum guest linear memory
max_module_size
usize
10 MiB
Maximum module size; a larger module is rejected before compilation

A chio.yaml entry is a WasmGuardEntry: it accepts name, path, fuel_limit, priority, and advisory, and rejects unknown fields. max_memory_bytes and max_module_size are set on WasmGuardConfig in Rust.

Loaded guard handles are sorted by priority, lower first. When advisory: true, the guard logs denials and errors and returns Verdict::Allow, which lets an operator run a new guard against production traffic without blocking it.

Security properties

  • Sandboxed execution. Subject to ASSUME-WASM-ENGINE, WASM guards execute in isolated linear memory with no direct host filesystem, network, or kernel access. Guest code can call only the declared chio host imports (section 6.5).
  • Bounded guest execution. Subject to ASSUME-WASM-ENGINE, fuel metering bounds guest instruction execution; wall-clock latency also includes the declared host imports (section 6.5). The backend applies max_memory_bytes through a store limiter and rejects an oversized module before compilation with ModuleTooLarge.
  • Allowlisted host callbacks. A core module may import chio.log, chio.get_config, and chio.get_time_unix_secs (KNOWN_HOST_FUNCTIONS). A Component Model guard uses the host interface of the guard world: log, get-config, get-time-unix-secs, and fetch-blob, plus the policy-context bundle-handle resource. load_module rejects any import outside the chio namespace with ImportViolation, and the policy loader rejects a chio import the guard entry did not declare in capabilities with LoadError::UndeclaredHostImport, after checking the declared capabilities against the policy allowlist (CapabilityDenied).
  • Fail-closed on all errors. Compilation failure, missing exports, fuel exhaustion, traps, and unexpected return values all result in denial for a non-advisory guard (section 6.5).

ASSUME-WASM-ENGINE is the audited platform assumption that wasmtime enforces its documented verdict, trap, fuel, memory-limiter, and in-process sandbox semantics. Chio models and tests its own typed verdict mapping and resource fail-closure; the engine’s interpreter, compiler, JIT, and sandbox correctness stay under that assumption.

Session journal contract

Session journal chainChoose a view.
0e0e1e2e3sequence8prev_hash8 + 64timestamp_secs8tool_name8 + nserver_id8 + nagent_id8 + nbytes_read8bytes_written8delegation_depth4allowed1sequence 0sequence 1sequence 2sequence 34be1…07c2a90d…3f1517c6…e8b0d2f4…5a91ZERO_HASHprev_hashpreimage of entry_hash, in orderSHA-256 to entry_hashIntegrityViolation { index: 1 }evicted_head_hashaccepted0e0e1e2e3sequence8prev_hash8 + 64timestamp_secs8tool_name8 + nserver_id8 + nagent_id8 + nbytes_read8bytes_written8delegation_depth4allowed1sequence 0sequence 1sequence 2sequence 34be1…07c2a90d…3f1517c6…e8b0d2f4…5a91ZERO_HASHprev_hashpreimage of entry_hashSHA-256 to entry_hashIntegrityViolationevicted_head_hashaccepted

Each entry commits to the one before it

Every entry carries prev_hash, the entry_hash of the entry before it. The first entry uses ZERO_HASH, 64 hex zeros. Entries are only appended, never modified or removed.

Four entries of one session journal and the preimage its entry hash covers. Hash values are illustrative; timestamps, identifiers, cumulative counters, and the ring capacity are left out.

The session journal, in chio-http-session, holds the shared session state that session-aware guards and advisory guards read. It is an append-only, hash-chained log of request records within one session.

Invariants

  • Append-only. Entries MUST only be added, never modified or removed.
  • Hash-chained. Each entry MUST include a SHA-256 hash of the previous entry for tamper detection. The first entry uses the zero hash (64 hex zeros) as its prev_hash (ZERO_HASH).
  • Thread-safe. The journal MUST be safe for concurrent access from multiple guards. The implementation takes one lock around its inner state for every read and write (lock_inner).
  • Per-session scope. Each session creates one journal, shared through Arc<SessionJournal> with every guard that needs it.

Journal entry

JournalEntry records one tool invocation:

FieldTypeDescription
sequence
u64
Monotonically increasing sequence number (0-based)
prev_hash
string
SHA-256 hex hash of the previous entry (zero hash for the first entry)
entry_hash
string
SHA-256 hex hash of this entry’s canonical fields
timestamp_secs
u64
Unix timestamp (seconds) when the entry was recorded
tool_name
string
Tool that was invoked
server_id
string
Server that hosted the tool
agent_id
string
Agent that made the invocation
bytes_read
u64
Bytes read during this invocation
bytes_written
u64
Bytes written during this invocation
delegation_depth
u32
Delegation depth at the time of invocation
allowed
boolean
Whether the invocation was allowed or denied

compute_entry_hash sets entry_hash to the SHA-256 digest of the fields below, concatenated in this order. Integers use little-endian byte encoding. Each string field is length-prefixed: its byte length is written first as an 8-byte little-endian u64, followed by the string’s UTF-8 bytes (update_len_prefixed). The length prefix is part of the preimage, so an implementation that hashes bare UTF-8 bytes computes a different digest and falsely reports tampering on a valid journal.

  1. sequence (8 bytes, LE)
  2. prev_hash (8-byte LE length prefix + UTF-8 bytes)
  3. timestamp_secs (8 bytes, LE)
  4. tool_name (8-byte LE length prefix + UTF-8 bytes)
  5. server_id (8-byte LE length prefix + UTF-8 bytes)
  6. agent_id (8-byte LE length prefix + UTF-8 bytes)
  7. bytes_read (8 bytes, LE)
  8. bytes_written (8 bytes, LE)
  9. delegation_depth (4 bytes, LE)
  10. allowed (1 byte: 0x01 for true, 0x00 for false)

Cumulative accounting

The journal maintains running statistics that guards read through data_flow() or a snapshot (CumulativeDataFlow): total_bytes_read, total_bytes_written, total_invocations, and max_delegation_depth. Every addition uses saturating arithmetic.

Tool sequence tracking

The journal maintains a tool sequence, an ordered list of tool names in invocation order that tool_sequence() returns, and tool counts, a map from tool name to invocation count that tool_counts() returns. The spec assigns the sequence to BehavioralSequenceGuard and the counts to AnomalyAdvisoryGuard (section 7.4). Both allowed and denied invocations are recorded: record() takes the allowed flag as data and appends to the sequence and the counts regardless of it.

The journal bounds two structures. The sequence is a bounded ring, so an old entry leaves it while the counts survive eviction. The map of distinct tool names is capped at tool_counts_cap: a tool already counted keeps counting, and once the cap is reached a new tool name stays out of the map, so a required-predecessor check treats it as never invoked and denies. The journal also keeps the current same-tool streak, current_streak_tool and current_streak_len, for the max-consecutive check.

Integrity verification

verify_integrity() walks the hash chain and checks two things for each entry:

  1. The entry’s prev_hash matches the preceding entry’s entry_hash, or the zero hash for the first entry. When the ring has evicted a prefix, the oldest retained entry’s link is accepted as is and only its entry hash is checked.
  2. The entry’s entry_hash matches the recomputed hash of its canonical fields.

If either check fails, the journal returns SessionJournalError::IntegrityViolation { index, expected, actual }. Evicted entry hashes fold into evicted_head_hash, and the exported head hash folds that value with the retained tail, so the head still commits to the evicted prefix.

Operators SHOULD invoke integrity verification at session boundaries or during audit. Guards MAY verify integrity before reading journal state, though this adds latency and is not required for normal operation (section 7.5).

Guard access patterns

Each journal guard reads one snapshot() (SessionJournalSnapshot) and uses these fields of it:

GuardSnapshot fieldsSpec getter (section 7.6)
DataFlowGuard
data_flow
data_flow()
BehavioralSequenceGuard
current_streak_tool, tool_counts, tool_sequence, and the streak length
tool_sequence()
AnomalyAdvisoryGuard
tool_counts, data_flow.max_delegation_depth
tool_counts(), data_flow()
DataTransferAdvisoryGuard
data_flow
data_flow()

Configuration

The guards: section of chio.yaml configures every built-in guard by name (section 8.1). Each key takes the options listed under its guard above; wasm_guards sits beside it at the top level.

spec/GUARDS.mdyaml
guards:
  # Stateless deterministic
  internal_network:
    extra_blocked_hosts:
      - "pricing-admin.larkfield.example"
    dns_rebinding_detection: true

  agent_velocity:
    max_requests_per_agent: 100
    max_requests_per_session: 20
    window_secs: 60
    burst_factor: 1.5

  # Session-aware deterministic
  data_flow:
    max_bytes_read: 10485760      # 10 MiB
    max_bytes_written: 5242880     # 5 MiB
    max_bytes_total: 15728640      # 15 MiB

  behavioral_sequence:
    required_predecessors:
      write_file:
        - run_command
    forbidden_transitions:
      - [read_file, fetch]
    max_consecutive: 8
    required_first_tool: read_file

  # Post-invocation
  response_sanitization:
    min_level: medium
    action: redact

  # Advisory
  advisory:
    anomaly:
      invocation_threshold: 20
      depth_threshold: 5
    data_transfer:
      bytes_threshold: 5242880    # 5 MiB
    promotion_rules:
      - guard_name: anomaly-advisory
        min_severity: high
      - guard_name: data-transfer-advisory
        min_severity: critical

GuardsConfig adds two switches and rejects unknown fields. allow_advisory_promotion (default false) gates whether advisory signals may be promoted at all, and required (default empty) names guards that must pass for every request, in addition to guards declared on individual routes.

chio.yamlyaml
guards:
  allow_advisory_promotion: true
  required:
    - internal-network
    - response-sanitization

Default runtime profile

Section 9 of the spec records, per guard, the crate that ships it and whether the standard runtime builder installs it or an operator wires it. ChioKernel::new is the bare constructor and installs no guards; the runtime builder installs the default profile.

GuardCrateDefault runtime profile
InternalNetworkGuard
chio-guards
Installed by default runtime profile
AgentVelocityGuard
chio-guards
Installed by default runtime profile
DataFlowGuard
chio-guards
Operator-wired (requires SessionJournal)
BehavioralSequenceGuard
chio-guards
Operator-wired (requires SessionJournal)
ResponseSanitizationGuard
chio-guards
Installed by default runtime profile through SanitizerHook
PostInvocationPipeline
chio-kernel
Installed by default runtime profile
AdvisoryPipeline
chio-guards
Installed by default runtime profile (session-journal advisory guards remain operator-wired)
AnomalyAdvisoryGuard
chio-guards
Operator-wired (requires SessionJournal)
DataTransferAdvisoryGuard
chio-guards
Operator-wired (requires SessionJournal)
PortableFilesystemRootsGuard
chio-guards
Operator-wired (filesystem-scoped)
WasmGuard
chio-wasm-guards
Operator-wired
WasmGuardRuntime
chio-wasm-guards
Operator-wired
SessionJournal
chio-http-session
Operator-wired

The session-aware guards (DataFlowGuard, BehavioralSequenceGuard) and the session-journal advisory guards (AnomalyAdvisoryGuard, DataTransferAdvisoryGuard) run once an operator wires a SessionJournal into the pipeline.

The runtime profile is a different list from GuardPipeline::default_pipeline(), which registers forbidden-path, shell-command, egress-allowlist, path-allowlist, mcp-tool, secret-leak, patch-integrity. None of those guards appears in the spec’s profile table. impl Default for GuardPipeline returns an empty pipeline.

Conformance

The cross-language WASM conformance runner loads the Rust, TypeScript, Python, and Go example guards and runs each against the fixtures under tests/conformance/fixtures/guard/ (tool-gate.yaml and enriched-fields.yaml), checking that the verdicts agree.

The escape tests cover undeclared imports, fuel exhaustion, oversize memory, deep recursion, table growth, host re-entry, malformed components, and signed-but-malicious modules.

Specification and implementation sources

The rules are spec/GUARDS.md, version 1.0, dated 2026-04-14, status Normative. The keywords MUST, SHOULD, and MAY are normative per RFC 2119. The implementing crates are in the Chio repository, cited below by file and line.

  • Guard trait: crates/kernel/chio-kernel/src/kernel/mod.rs lines 573 to 632.
  • Data guards: crates/guards/chio-data-guards/src/lib.rs lines 1 to 30.
  • Pipeline registration and evaluation: crates/guards/chio-guards/src/pipeline.rs lines 11 to 27 and 58 to 67; fail-closed evaluate lines 63 to 106; Allow lines 71 and 102 to 105; PendingApproval lines 72 to 78; Deny lines 79 to 88; default pipeline lines 39 to 49; impl Default lines 52 to 56.
  • Verdict, ApprovalRequest, and the portable-core mapping: crates/kernel/chio-kernel/src/runtime.rs lines 30 to 38, 22 to 28, and 40 to 47.
  • GuardEvidence: crates/core/chio-core-types/src/receipt/metadata.rs lines 186 to 194.
  • InternalNetworkGuard: crates/guards/chio-guards/src/internal_network.rs; guard name line 99; address checks lines 129 to 213; unspecified address lines 177 to 180; metadata hosts lines 204 to 207; options lines 27 to 37; check_host lines 53 to 88 (reasons at 58, 64, 70, 76, 84; unresolved hostnames 81 to 87); evaluate lines 103 to 116.
  • AgentVelocityGuard: crates/guards/chio-guards/src/agent_velocity.rs; guard name line 256; milli-tokens line 7; buckets lines 261 to 266 and 292; peek before consume lines 309 to 326; poisoned lock lines 270 to 272; bucket cap lines 278 to 284; denials lines 283 and 319; AgentVelocityConfig lines 101 to 121.
  • DataFlowGuard: crates/guards/chio-guards/src/data_flow.rs; guard name line 54; config lines 25 to 29; snapshot and journal error lines 58 to 61; limits lines 64 to 85.
  • BehavioralSequenceGuard: crates/guards/chio-guards/src/behavioral_sequence.rs; guard name line 60; SequencePolicy lines 26 to 39; snapshot lines 66 to 70; first tool lines 72 to 85; predecessors lines 87 to 100; streak lines 128 to 134.
  • PostInvocationPipeline: crates/kernel/chio-kernel/src/post_invocation.rs; type line 181; verdicts lines 13 to 18; evaluation lines 311 to 356 (Block 328 to 334, Redact 335 to 337, Escalate 338 to 340 and 346 to 347, final verdict 344 to 350); durable path lines 271 to 276.
  • ResponseSanitizationGuard: crates/guards/chio-guards/src/response_sanitization/simple.rs; guard name line 237; levels lines 13 to 20; actions lines 37 to 42; patterns lines 44 to 89; catch-all lines 100 to 116; new lines 130 to 136; additional patterns lines 150 to 162; min_level lines 167 to 169 and 181 to 183; scan results lines 196 to 213 and 217; build_pattern lines 259 to 272.
  • Advisory framework: crates/guards/chio-guards/src/advisory.rs; severity lines 28 to 41; signal lines 45 to 59; GuardOutput lines 61 to 73; serialized policy lines 102 to 116; add_rule lines 118 to 127; should_promote lines 130 to 136; severity_ord lines 143 to 151; evidence details lines 170 to 175; AdvisoryPipeline::new line 198; stored signals lines 222 to 234 and 259 to 264; guard name lines 237 to 240; evaluate lines 242 to 271; dispatch lines 273 to 278; anomaly guard lines 293 to 366; data-transfer guard lines 383 to 426.
  • WASM runtime: crates/guards/chio-wasm-guards/src/host.rs lines 41 to 49; src/abi.rs lines 15, 18, and 29 to 58; src/config.rs lines 5 to 6 and 20 to 67; src/error.rs lines 24 and 36; src/runtime/guard.rs lines 349 to 364, 425 to 432, and 443 to 464; src/runtime/wasmtime_backend.rs lines 57 to 60, 563 to 591, 607 to 611, 631 to 726, 772 to 818, 844 to 845, 966 to 976, 1089 to 1090, and 1286 to 1296.
  • WASM entries in chio.yaml: crates/platform/chio-config/src/schema.rs lines 350 to 377; GuardsConfig lines 324 to 337.
  • Engine assumption: docs/reference/CLAIM_REGISTRY.md line 53 and docs/release/RISK_REGISTER.md line 27.
  • Session journal: crates/platform/chio-http-session/src/lib.rs; entry lines 61 to 85; zero hash line 88; length prefix lines 90 to 94; entry hash lines 97 to 110; cumulative state lines 125 to 134; snapshot lines 138 to 156; evicted head hash lines 185 to 202 and 255 to 275; record() lines 397 to 475; lock line 402; saturating addition lines 429 to 441; getters lines 503 to 513; integrity lines 555 to 595.
  • Conformance: crates/guards/chio-wasm-guards/tests/conformance_runner.rs and crates/guards/chio-wasm-guards/tests/escape/.

Next: watch the pipeline decide one call