Guards Spec
Find the exact rule a guard follows: its category, fail-closed obligations, verdict, evidence, WASM limits, and session journal reads.
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.
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.
Try internal-network, PendingApproval, fuel_limit, or prev_hash. Each entry has its own link.
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).
| Category | State | Phase | Blocking |
|---|---|---|---|
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):
- Stateless deterministic guards (cheapest, no I/O).
- Session-aware deterministic guards (require a journal read).
- WASM custom guards (sandboxed execution, potentially expensive).
- 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
forbidden-path denies the request; the pipeline stops and the request is not dispatched.
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 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
Allowis 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:
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 returnsAllowwith 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 returnsPendingApprovalwhen none does. TheApprovalRequesttravels 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.
| Variant | Tag | Fields | Description |
|---|---|---|---|
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):
| Class | Range | Rationale |
|---|---|---|
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., metadata. | Cloud instance credentials |
Kubernetes | kubernetes., kubernetes. | Cluster- |
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.
| Option | Type | Default | Description |
|---|---|---|---|
extra_ | string[] | [] | Additional hostnames to block beyond the built-in set |
dns_ | 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 inextra_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.
| Bucket | Key | Purpose |
|---|---|---|
Per-agent | agent_id | Cross- |
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).
| Option | Type | Default | Description |
|---|---|---|---|
max_ | u32 or null | null (unlimited) | Maximum requests per agent per window |
max_ | 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.
| Option | Type | Default | Description |
|---|---|---|---|
max_bytes_read | u64 or null | null (unlimited) | Maximum cumulative bytes read per session |
max_ | 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.
| Option | Type | Default | Description |
|---|---|---|---|
required_ | HashMap<String, HashSet<String>> | empty | Tool name to the set of tools that must have run before it |
forbidden_ | 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_ | 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
Blockfrom any hook MUST stop the pipeline immediately. No subsequent hooks run; the outcome carries the evidence collected so far. - A
Redactreplaces the response for all subsequent hooks. MultipleRedacthooks compose sequentially. Escalatemessages are collected throughout the pipeline and joined with;in the final verdict.- The final verdict is
Redactwhen the response changed,Escalatewhen any hook escalated, and otherwiseAllow.
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.
| Pattern | Example | Sensitivity | Redaction |
|---|---|---|---|
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):
| Option | Type | Default | Description |
|---|---|---|---|
min_level | Sensitivity | Low | Minimum sensitivity level to trigger |
action | Sanitization | Block | Action to take on a match |
custom_patterns | Sensitive | [] | 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_responsereturnsScanResult::Clean,ScanResult::Blocked(findings), orScanResult::Redacted { redacted_text, redaction_count, findings }.
Advisory pipeline and promotion
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.Advisory guardsAdvisory 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
| Field | Type | Description |
|---|---|---|
guard_name | string | Name of the advisory guard that produced the signal |
description | string | Human-readable observation |
severity | Advisory | 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
| Level | Ordinal | Meaning |
|---|---|---|
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:
- Evaluates every registered advisory guard in order. A guard error propagates as the pipeline’s error, which the outer pipeline treats as a denial.
- Each guard returns zero or more
AdvisorySignalentries. - For each signal, the pipeline checks the promotion policy.
- If any signal matches a promotion rule, it is marked
promoted: trueand the pipeline returnsDeny. - If no signal is promoted, the pipeline returns
Allowwith the signal evidence. - All collected signals, promoted or not, are stored for evidence export through
last_signals()andlast_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.
advisory:
promotion_rules:
- guard_name: anomaly-advisory
min_severity: high
- guard_name: data-transfer-advisory
min_severity: critical| PromotionRule field | Type | Description |
|---|---|---|
guard_name | string | Exact match on the advisory guard’s name |
min_severity | Advisory | 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.
| Condition | Severity | Description |
|---|---|---|
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 |
| Option | Type | Default | Description |
|---|---|---|---|
invocation_ | 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.
| Condition | Severity | Description |
|---|---|---|
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):
evaluate(request_ptr: i32, request_len: i32) -> i32The core-module invocation protocol (section 6.1) runs in four steps:
- The host serializes a
GuardRequestas JSON. - 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. - The host writes the JSON bytes at the selected offset and calls
evaluate(request_offset, json_length). - 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:
chio guard inspect target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm=== 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.
| Field | Type | Description |
|---|---|---|
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_ | string or absent | Target domain for network egress actions |
filesystem_ | string[] or absent | Session-scoped filesystem roots |
matched_ | 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:
chio guard bench target/wasm32-unknown-unknown/release/tool_denylist_guard.wasm \
--iterations 100 --fuel-limit 1000000=== 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):
wasm_guards:
- name: custom-pii-guard
path: /etc/chio/guards/pii_guard.wasm
fuel_limit: 5000000
priority: 100
advisory: falseThe options and their defaults are WasmGuardConfig:
| Option | Type | Default | Description |
|---|---|---|---|
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_ | 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 declaredchiohost 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 appliesmax_memory_bytesthrough a store limiter and rejects an oversized module before compilation withModuleTooLarge. - Allowlisted host callbacks. A core module may import
chio.log,chio.get_config, andchio.get_time_unix_secs(KNOWN_HOST_FUNCTIONS). A Component Model guard uses thehostinterface of theguardworld:log,get-config,get-time-unix-secs, andfetch-blob, plus thepolicy-contextbundle-handle resource.load_modulerejects any import outside thechionamespace withImportViolation, and the policy loader rejects achioimport the guard entry did not declare incapabilitieswithLoadError::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
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.
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:
| Field | Type | Description |
|---|---|---|
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_ | 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.
sequence(8 bytes, LE)prev_hash(8-byte LE length prefix + UTF-8 bytes)timestamp_secs(8 bytes, LE)tool_name(8-byte LE length prefix + UTF-8 bytes)server_id(8-byte LE length prefix + UTF-8 bytes)agent_id(8-byte LE length prefix + UTF-8 bytes)bytes_read(8 bytes, LE)bytes_written(8 bytes, LE)delegation_depth(4 bytes, LE)allowed(1 byte:0x01for true,0x00for 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:
- The entry’s
prev_hashmatches the preceding entry’sentry_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. - The entry’s
entry_hashmatches 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:
| Guard | Snapshot fields | Spec getter (section 7.6) |
|---|---|---|
DataFlowGuard | data_flow | data_flow() |
Behavioral | current_, tool_counts, tool_sequence, and the streak length | tool_sequence() |
Anomaly | tool_counts, data_ | tool_counts(), data_flow() |
Data | 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.
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: criticalGuardsConfig 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.
guards:
allow_advisory_promotion: true
required:
- internal-network
- response-sanitizationDefault 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.
| Guard | Crate | Default runtime profile |
|---|---|---|
Internal | chio-guards | Installed by default runtime profile |
Agent | chio-guards | Installed by default runtime profile |
DataFlowGuard | chio-guards | Operator-wired (requires SessionJournal) |
Behavioral | chio-guards | Operator-wired (requires SessionJournal) |
Response | chio-guards | Installed by default runtime profile through SanitizerHook |
Post | chio-kernel | Installed by default runtime profile |
Advisory | chio-guards | Installed by default runtime profile (session-journal advisory guards remain operator-wired) |
Anomaly | chio-guards | Operator-wired (requires SessionJournal) |
Data | chio-guards | Operator-wired (requires SessionJournal) |
Portable | chio-guards | Operator-wired (filesystem-scoped) |
WasmGuard | chio- | Operator-wired |
Wasm | chio- | Operator-wired |
SessionJournal | chio- | 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.rslines 573 to 632. - Data guards:
crates/guards/chio-data-guards/src/lib.rslines 1 to 30. - Pipeline registration and evaluation:
crates/guards/chio-guards/src/pipeline.rslines 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 Defaultlines 52 to 56. - Verdict, ApprovalRequest, and the portable-core mapping:
crates/kernel/chio-kernel/src/runtime.rslines 30 to 38, 22 to 28, and 40 to 47. - GuardEvidence:
crates/core/chio-core-types/src/receipt/metadata.rslines 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_hostlines 53 to 88 (reasons at 58, 64, 70, 76, 84; unresolved hostnames 81 to 87);evaluatelines 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;AgentVelocityConfiglines 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;SequencePolicylines 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;newlines 130 to 136; additional patterns lines 150 to 162;min_levellines 167 to 169 and 181 to 183; scan results lines 196 to 213 and 217;build_patternlines 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_rulelines 118 to 127;should_promotelines 130 to 136;severity_ordlines 143 to 151; evidence details lines 170 to 175;AdvisoryPipeline::newline 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.rslines 41 to 49;src/abi.rslines 15, 18, and 29 to 58;src/config.rslines 5 to 6 and 20 to 67;src/error.rslines 24 and 36;src/runtime/guard.rslines 349 to 364, 425 to 432, and 443 to 464;src/runtime/wasmtime_backend.rslines 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.rslines 350 to 377;GuardsConfiglines 324 to 337. - Engine assumption:
docs/reference/CLAIM_REGISTRY.mdline 53 anddocs/release/RISK_REGISTER.mdline 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.rsandcrates/guards/chio-wasm-guards/tests/escape/.