ReferenceSpec
Guards Spec
The guard rules in spec/GUARDS.md 1.0: categories, fail-closed behavior, verdicts, evidence, WASM limits, and the session journal contract.
Source
This page normatively reflects spec/GUARDS.md in the chio repository. Status: Normative. Version 1.0, dated 2026-04-14. The keywords MUST, SHOULD, and MAY are normative per RFC 2119.
Behavior is checked against the crates: crates/guards/chio-guards (the pipeline and the built-in guards), crates/kernel/chio-kernel (the Guard trait, Verdict, and the post-invocation pipeline), crates/guards/chio-wasm-guards (the WASM runtime), crates/platform/chio-http-session (the session journal), and crates/platform/chio-config (the loader for chio.yaml). Where the spec and a crate disagree, the crate is the behavior, cited by line.
Synopsis
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;
}The Guard trait at crates/kernel/chio-kernel/src/kernel/mod.rs lines 573 to 632, reduced to its method signatures: a guard implements name and evaluate, and the source gives every other method a default body.
Guard pipeline overview
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 (crates/guards/chio-data-guards/src/lib.rs lines 1 to 30). The taxonomy does not classify it. Its guards 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.
In the crate, 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)) (crates/guards/chio-guards/src/pipeline.rs lines 11 to 27 and 58 to 67). The order the spec recommends holds when the operator adds guards in that order.
Fail-closed semantics
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 at pipeline.rs lines 63 to 106. 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 do not run and leave no evidence.
Per-guard fail-closed obligations are listed under each guard. See Kernel: fail-closed for the kernel-side treatment.
Verdicts
GUARDS.md names Allow and Deny. The kernel verdict type has a third variant, PendingApproval, at crates/kernel/chio-kernel/src/runtime.rs lines 30 to 38:
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,
}| Verdict | Effect in GuardPipeline |
|---|---|
Allow | The pipeline continues to the next guard. When every guard allows, the pipeline returns Allow with the collected evidence (pipeline.rs lines 71 and 102 to 105). |
Deny | The pipeline returns at once with the evidence collected so far plus its own deny entry (lines 79 to 88). |
PendingApproval | Sticky. The pipeline keeps evaluating the remaining guards so a later guard can still deny, and returns PendingApproval when none does (lines 72 to 78). The ApprovalRequest travels separately through the HITL API (runtime.rs lines 22 to 28). |
For the portable core, From<Verdict> for chio_kernel_core::GuardStep maps PendingApproval to GuardStep::Error (runtime.rs lines 40 to 47).
Evidence
GuardEvidence (crates/core/chio-core-types/src/receipt/metadata.rs lines 186 to 194) 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
The GuardOutput type (crates/guards/chio-guards/src/advisory.rs lines 61 to 73) 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 (crates/guards/chio-guards/src/internal_network.rs line 99). 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; is_private_ip and is_cloud_metadata_host at lines 129 to 213):
| 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.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 crate also blocks the IPv6 unspecified address :: (lines 177 to 180) and treats instance-data and any hostname ending in .internal as cloud metadata hosts (lines 204 to 207); the spec table omits both.
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 does not resolve hostnames: a hostname that matches none of the checks passes (lines 81 to 87).
| Option | Type | Default | Description |
|---|---|---|---|
extra_blocked_hosts | string[] | [] | Additional hostnames to block beyond the built-in set (lines 27 and 36) |
dns_rebinding_detection | bool | true | Enable the dash and dot embedded-IP heuristic (lines 29 and 37) |
Evidence reasons. check_host (lines 53 to 88) returns one of these reason strings when it blocks a host:
| Reason | Trigger |
|---|---|
cloud metadata endpoint: <host> | A metadata IP or hostname (line 58) |
blocked host: <host> | A host in extra_blocked_hosts (line 64) |
DNS rebinding suspect: <host> | The rebinding heuristic, when enabled (line 70) |
private/reserved IP: <ip> | A literal IP in a blocked range (line 76) |
encoded IP pattern in hostname: <host> | A hostname that looks like an encoded IP (line 84) |
The spec says the denial evidence carries the blocked host and this reason (section 2.1). In the crate, evaluate discards the reason and returns a Deny with no evidence entry of its own (lines 113 to 116); the entry that reaches the receipt is the pipeline's generic deny entry.
Fail-closed
NetworkEgress actions (lines 108 to 111).AgentVelocityGuard
Guard name: agent-velocity (crates/guards/chio-guards/src/agent_velocity.rs line 256). 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 (line 7).
| Bucket | Key | Purpose |
|---|---|---|
| Per-agent | agent_id | Cross-capability rate limit for one agent (line 292) |
| Per-session | (agent_id, capability_id) | Rate limit within one capability context (lines 261 to 266) |
When both limits are configured, both MUST pass. The guard peeks both buckets before consuming from either, so a denied request burns no token (lines 309 to 326). A poisoned state lock returns KernelError::Internal, which the pipeline treats as a denial (lines 270 to 272). The guard also bounds how many buckets it keeps: a request that would create a bucket beyond bucket_cap denies (lines 278 to 284). Denials carry no evidence entry of their own (lines 283 and 319).
| Option | Type | Default | Description |
|---|---|---|---|
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 |
AgentVelocityConfig and its defaults are at lines 101 to 121.
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. Neither DataFlowGuard nor BehavioralSequenceGuard is in GuardPipeline::default_pipeline() (pipeline.rs lines 39 to 49); an operator registers them with a journal the runtime caller supplies (section 3).
DataFlowGuard
Guard name: data-flow (crates/guards/chio-guards/src/data_flow.rs line 54). 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() (lines 58 to 61) and compares running totals, not per-request deltas; a total at or above a limit denies (lines 64 to 85).
| Option | Type | Default | Description |
|---|---|---|---|
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 |
DataFlowConfig is at lines 25 to 29. 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 crate maps the journal error to KernelError::Internal (lines 58 to 60).
BehavioralSequenceGuard
Guard name: behavioral-sequence (crates/guards/chio-guards/src/behavioral_sequence.rs line 60). Enforces tool ordering policies. The guard reads one journal snapshot (lines 66 to 70) and checks the constraints of its SequencePolicy (lines 26 to 39):
| Constraint | Description |
|---|---|
| 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_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 rather than the bounded tool sequence: the required-first-tool check reads current_streak_tool, which is unset only before the first record (lines 72 to 85), and the required-predecessor check reads the cumulative tool_counts, which survives ring eviction (lines 87 to 100). The max-consecutive check reads the journal's streak counter (lines 128 to 134). A journal error returns KernelError::Internal, which the pipeline treats as a denial (lines 66 to 70).
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.
PostInvocationPipeline
PostInvocationPipeline is defined in the kernel crate, crates/kernel/chio-kernel/src/post_invocation.rs line 181, and the spec's implementation table places it in chio-guards. It evaluates hooks in registration order. Each hook returns one PostInvocationVerdict (lines 13 to 18):
| Verdict | Effect |
|---|---|
Allow | Response passes through unmodified |
Block(reason) | Response is replaced with an error message; the pipeline short-circuits |
Redact(value) | Response content is replaced with the redacted version; subsequent hooks see the redacted version |
Escalate(message) | Response is delivered, and an escalation signal is emitted for operator review |
Pipeline semantics (section 4.1; evaluate_with_context_and_evidence at lines 311 to 356):
- A
Blockfrom any hook MUST stop the pipeline immediately. No subsequent hooks run; the outcome carries the evidence collected so far (lines 328 to 334). - A
Redactreplaces the response for all subsequent hooks. MultipleRedacthooks compose sequentially (lines 335 to 337). Escalatemessages are collected throughout the pipeline and joined with;in the final verdict (lines 338 to 340 and 346 to 347).- The final verdict is
Redactwhen the response changed,Escalatewhen any hook escalated, and otherwiseAllow(lines 344 to 350).
On the durable admission path, hooks are non-blocking by contract: a Block there is an error (lines 271 to 276).
ResponseSanitizationGuard
Guard name: response-sanitization (crates/guards/chio-guards/src/response_sanitization/simple.rs line 237). 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 are default_patterns() at lines 44 to 89; 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, lines 13 to 20):
| Level | Meaning |
|---|---|
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 (lines 167 to 169 and 181 to 183). Actions (SanitizationAction, lines 37 to 42):
| Action | Behavior |
|---|---|
Block | Deny the response entirely if any pattern matches |
Redact | Replace matching patterns with their redaction strings and allow the response |
Configuration (section 4.2):
| Option | Type | Default | Description |
|---|---|---|---|
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 |
The defaults in that table are the spec's. The crate has no default constructor: ResponseSanitizationGuard::new(min_level, action) takes both values (lines 130 to 136), and additional patterns come through with_additional_patterns (lines 150 to 162).
Operators MAY define custom patterns through build_pattern(name, regex, level, redaction), which returns None for an invalid regex (lines 259 to 272). 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 (lines 100 to 116); this is stricter than the per-pattern skip the spec describes in section 4.2.
Dual-phase operation:
- Pre-invocation (Guard trait): scans request arguments for PII that should not reach tool servers; denies if patterns are found.
- Post-invocation:
scan_responsereturnsScanResult::Clean,ScanResult::Blocked(findings), orScanResult::Redacted { redacted_text, redaction_count, findings }(lines 196 to 213 and 217).
Advisory pipeline and promotion
Advisory signals are non-blocking observations emitted during guard evaluation. They give operators visibility into request patterns without changing the verdict, unless a promotion rule promotes them.
AdvisorySignal
AdvisorySignal is at crates/guards/chio-guards/src/advisory.rs lines 45 to 59.
| Field | Type | Description |
|---|---|---|
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 (line 53) |
promoted | boolean | Whether the promotion policy promoted this signal to a denial; the guard sets it false (lines 55 to 58) |
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=... (lines 170 to 175).
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 (lines 28 to 41) serializes in snake_case; severity_ord (lines 143 to 151) supplies the ordinal. A promotion rule with min_severity: Medium promotes signals at Medium, High, and Critical.
AdvisoryPipeline behavior
The AdvisoryPipeline wrapsAdvisoryGuard implementations and a PromotionPolicy. It implements the kernel's Guard trait, so it registers in the standard pipeline under the name advisory-pipeline (lines 237 to 240). Its evaluate (lines 242 to 271):
- Evaluates every registered advisory guard in order. A guard error propagates as the pipeline's error (line 247), 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(lines 249 to 252 and 266 to 267). - If no signal is promoted, the pipeline returns
Allowwith the signal evidence (lines 268 to 270). - All collected signals, promoted or not, are stored for evidence export through
last_signals()andlast_outputs()(lines 222 to 234 and 259 to 264).
Without any promotion rule, the advisory pipeline MUST always return Allow (section 5.3). At dispatch it does not evaluate a second time; it keeps the admission verdict and evidence (lines 273 to 278).
PromotionPolicy
A promotion policy is built from PromotionRule values through PromotionPolicy::add_rule (lines 118 to 127) and passed to AdvisoryPipeline::new (line 198). No key in GuardsConfig loads one (see Configuration). Both types derive serde; a serialized policy is a rules list with snake_case severity values (lines 102 to 116):
# PromotionPolicy as serde serializes it
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 | 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, lines 130 to 136); the pipeline then sets promoted to true and returns Deny.
Built-in advisory guards
AnomalyAdvisoryGuard (anomaly-advisory, line 315): flags unusual invocation patterns and excessive delegation depth. It reads one journal snapshot (lines 321 to 325).
| Condition | Severity | Description |
|---|---|---|
| Tool invoked >= threshold times | Medium | Tool X invoked N times (threshold: T); metadata tool_name, count, and threshold (lines 329 to 346) |
| Tool invoked >= 2x threshold | High | Elevated severity for sustained repetition (line 336) |
| Delegation depth >= threshold | High | Delegation depth D exceeds threshold T (lines 354 to 366) |
| Option | Type | Default | Description |
|---|---|---|---|
invocation_threshold | u64 | (required) | Per-tool invocation count that triggers a signal (line 293) |
depth_threshold | u32 | (required) | Delegation depth that triggers a signal (line 295) |
DataTransferAdvisoryGuard (data-transfer-advisory, line 393): flags sessions with high cumulative data transfer, as an early warning before the deterministic DataFlowGuard limit is reached. It takes one bytes_threshold (line 383) and reads one journal snapshot (lines 397 to 400).
| 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 |
The severity ladder is at lines 406 to 410. Signals carry total_bytes, bytes_read, bytes_written, and threshold in metadata (lines 423 to 426). See Kernel: advisory.
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 (bindings at crates/guards/chio-wasm-guards/src/host.rs lines 41 to 49), and tells the two apart from the bytes (detect_wasm_format, crates/guards/chio-wasm-guards/src/runtime/wasmtime_backend.rs lines 57 to 60).
Host-guest ABI
Each raw core-module guard MUST export (section 6.1):
evaluate(request_ptr: i32, request_len: i32) -> i32Core-module invocation protocol (section 6.1; wasmtime_backend.rs lines 631 to 697):
- The host serializes a
GuardRequestas JSON (line 632). - 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 (lines 640 to 663). Without the export, the host writes at offset 0 (line 665). - The host writes the JSON bytes at the selected offset and calls
evaluate(request_offset, json_length)(lines 676 to 680). - The guest reads the request, evaluates it, and returns a verdict code.
| Code | Meaning |
|---|---|
0 | Allow (VERDICT_ALLOW, abi.rs line 15) |
1 | Deny (VERDICT_DENY, line 18) |
Any other i32 | Error. The backend reports it as a trap, unexpected return value from evaluate (wasmtime_backend.rs lines 721 to 726), which a blocking guard turns into a denial |
Deny reason protocol. 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 (lines 772 to 773), 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 (lines 793 to 802). Without the export, the host reads a NUL-terminated UTF-8 reason from offset 65536, at most 4096 bytes (lines 810 to 818). An absent, empty, malformed, or failed reason keeps the deny verdict and omits the reason.
GuardRequest
The JSON payload written into guest memory, from crates/guards/chio-wasm-guards/src/abi.rs lines 29 to 58. 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_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 (lines 41 to 43). When host-side action extraction fails, the guard denies before the guest runs, under the action type malformed_arguments (crates/guards/chio-wasm-guards/src/runtime/guard.rs lines 349 to 364).
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 (crates/guards/chio-wasm-guards/src/config.rs lines 5 to 6), and chio-config applies the same default to a chio.yaml entry (crates/platform/chio-config/src/schema.rs lines 375 to 377). After each call the backend records the fuel consumed (wasmtime_backend.rs lines 699 to 702) and classifies a failed call: a message that mentions fuel becomes WasmGuardError::FuelExhausted { consumed, limit }, anything else becomes WasmGuardError::Trap (lines 681 to 697; the variants are at error.rs lines 24 and 36).
Fail-closed for blocking guards
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. In the crate, WasmGuard::evaluate turns any backend error into Deny for a blocking guard and Allow for an advisory one (runtime/guard.rs lines 443 to 464), and a guest Deny from an advisory guard also becomes Allow (lines 425 to 432).Missing exports. The spec says a module without the evaluate function or the memory export MUST fail to load and MUST NOT register in the pipeline (section 6.3). The backend checks both exports when it evaluates and reports WasmGuardError::MissingExport then (lines 636 to 638 and 676 to 677); load_module checks the size and the imports and pre-instantiates the module (lines 563 to 591). A blocking guard whose module lacks an export therefore denies each invocation.
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: falseWasmGuardConfig (crates/guards/chio-wasm-guards/src/config.rs lines 20 to 51, defaults at lines 53 to 67):
| 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 (lines 27 to 30) |
priority | u32 | 1000 | Evaluation order; lower values run first (lines 32 to 35) |
advisory | bool | false | If true, a denial or error is logged and not enforced (lines 37 to 40) |
max_memory_bytes | usize | 16 MiB | Maximum guest linear memory (lines 42 to 45 and 61 to 63) |
max_module_size | usize | 10 MiB | Maximum module size; a larger module is rejected before compilation (lines 47 to 50 and 65 to 67) |
The chio.yaml loader's WasmGuardEntry (schema.rs lines 350 to 373) accepts name, path, fuel_limit, priority, and advisory, and rejects unknown fields, so a chio.yaml entry cannot carry max_memory_bytes or max_module_size; those two fields belong to WasmGuardConfig.
Loaded guard handles are sorted by priority, lower first (wasmtime_backend.rs lines 1089 to 1090). When advisory: true, the guard logs denials and errors and returns Verdict::Allow (runtime/guard.rs lines 425 to 432 and 459 to 461), 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 (wasmtime_backend.rslines 607 to 611) and rejects an oversized module before compilation withModuleTooLarge(lines 564 to 570). - Allowlisted host callbacks: a core module may import
chio.log,chio.get_config, andchio.get_time_unix_secs(KNOWN_HOST_FUNCTIONS, lines 844 to 845). A Component Model guard uses thehostinterface of theguardworld:log,get-config,get-time-unix-secs, andfetch-blob, plus thepolicy-contextbundle-handle resource (wit/chio-guard/world.wit).load_modulerejects any import outside thechionamespace withImportViolation(lines 575 to 583), and the policy loader rejects achioimport the guard entry did not declare incapabilitieswithLoadError::UndeclaredHostImport(lines 1286 to 1296), after checking the declared capabilities against the policy allowlist (CapabilityDenied, lines 966 to 976). - 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;
runtime/guard.rslines 443 to 464).
ASSUME-WASM-ENGINE is the audited platform assumption that wasmtime enforces its documented verdict, trap, fuel, memory-limiter, and in-process sandbox semantics (docs/reference/CLAIM_REGISTRY.md line 53). 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 (docs/release/RISK_REGISTER.md line 27).
Session journal contract
The session journal (crates/platform/chio-http-session/src/lib.rs) 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, line 88). - 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, for example at line 402). - Per-session scope: each session creates one journal, shared through
Arc<SessionJournal>with every guard that needs it.
Journal entry
JournalEntry (lines 61 to 85) 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_depth | u32 | Delegation depth at the time of invocation |
allowed | boolean | Whether the invocation was allowed or denied |
Entry hash computation (compute_entry_hash, lines 97 to 110): the entry_hash is the SHA-256 digest of the fields 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, lines 90 to 94). 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, lines 125 to 134): total_bytes_read, total_bytes_written, total_invocations, and max_delegation_depth. Every addition uses saturating arithmetic (lines 429 to 441).
Tool sequence tracking
The journal maintains a tool sequence, an ordered list of tool names in invocation order that tool_sequence() returns (lines 503 to 507), and tool counts, a map from tool name to invocation count that tool_counts() returns (lines 509 to 513). 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 (lines 397 to 425, 454, and 469 to 475).
Two bounds apply in the crate that the spec does not state. 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 is not inserted, so a required-predecessor check treats it as never invoked and denies (lines 443 to 453 and 469 to 475). The journal also keeps the current same-tool streak, current_streak_tool and current_streak_len, for the max-consecutive check (lines 456 to 467).
Integrity verification
verify_integrity() (lines 555 to 595) walks the hash chain and checks:
- Each entry's
prev_hashmatches the preceding entry'sentry_hash, or the zero hash for the first entry. When a prefix has been evicted from the ring, the oldest retained entry's link is accepted as is and only its entry hash is checked (lines 561 to 570). - Each entry's
entry_hashmatches the recomputed hash of its canonical fields (lines 583 to 591).
If either check fails, the journal returns SessionJournalError::IntegrityViolation { index, expected, actual } (lines 576 to 580 and 586 to 590). 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
The spec names one getter per guard (section 7.6). In the crates each guard reads one snapshot() (SessionJournalSnapshot, lines 138 to 156) and uses these fields of it:
| Guard | Snapshot fields | Source |
|---|---|---|
DataFlowGuard | data_flow | data_flow.rs lines 58 to 61 |
BehavioralSequenceGuard | current_streak_tool, tool_counts, tool_sequence, and the streak length | behavioral_sequence.rs lines 66 to 134 |
AnomalyAdvisoryGuard | tool_counts, data_flow.max_delegation_depth | advisory.rs lines 321 to 325 and 354 |
DataTransferAdvisoryGuard | data_flow | advisory.rs lines 397 to 400 |
Configuration
The guards: section of chio.yaml that the runtime config loader enforces is GuardsConfig (crates/platform/chio-config/src/schema.rs lines 324 to 337). It exposes the keys below and rejects unknown fields:
guards:
allow_advisory_promotion: true
required:
- internal-network
- response-sanitizationallow_advisory_promotion (default false) gates whether advisory signals may be promoted at all; required (default empty) names guards that must pass for every request, in addition to guards declared on individual routes.
The per-guard keys GUARDS.md shows under guards: in section 8.1 (internal_network, agent_velocity, data_flow, behavioral_sequence, response_sanitization, and advisory) have no loader: GuardsConfig rejects them as unknown fields. Those guards take their options through the constructors cited under each guard. The wasm_guards array is loaded through WasmGuardEntry (see WASM configuration).
Implementation status
Section 9 of the spec reports, per guard, whether the logic ships in the named crate and whether the standard runtime builder installs it. ChioKernel::new is the bare constructor and installs no guards, so an implementation marked Full does not by itself mean the guard runs against incoming requests.
crates/guards/chio-guards/src/pipeline.rs:39-49at fe56570The default pipeline and the spec's implementation table share no guard name: forbidden-path, shell-command, egress-allowlist, path-allowlist, mcp-tool, secret-leak, patch-integrity are outside the table, and every guard in the table is outside default_pipeline(). The Default control-plane profile column describes the runtime builder, which is a different thing from GuardPipeline::default_pipeline(); impl Default for GuardPipeline returns an empty pipeline (pipeline.rs lines 52 to 56).
| Guard | Crate (spec) | Implementation | Default control-plane profile | Struct found | Defined in |
|---|---|---|---|---|---|
InternalNetworkGuard | chio-guards | Full | Installed by default runtime profile | Yes | chio-guards |
AgentVelocityGuard | chio-guards | Full | Installed by default runtime profile | Yes | chio-guards |
DataFlowGuard | chio-guards | Full | Operator-wired (requires SessionJournal) | Yes | chio-guards |
BehavioralSequenceGuard | chio-guards | Full | Operator-wired (requires SessionJournal) | Yes | chio-guards |
ResponseSanitizationGuard | chio-guards | Full | Installed by default runtime profile through SanitizerHook | Yes | chio-guards |
PostInvocationPipeline | chio-guards | Full | Installed by default runtime profile | No | chio-kernel |
AdvisoryPipeline | chio-guards | Full | Installed by default runtime profile (session-journal advisory guards remain operator-wired) | Yes | chio-guards |
AnomalyAdvisoryGuard | chio-guards | Full | Operator-wired (requires SessionJournal) | Yes | chio-guards |
DataTransferAdvisoryGuard | chio-guards | Full | Operator-wired (requires SessionJournal) | Yes | chio-guards |
PortableFilesystemRootsGuard | chio-guards | Full | Operator-wired (filesystem-scoped) | No | not found |
WasmGuard | chio-wasm-guards | Full | Operator-wired | Yes | chio-wasm-guards |
WasmGuardRuntime | chio-wasm-guards | Full | Operator-wired | Yes | chio-wasm-guards |
SessionJournal | chio-http-session | Full | Operator-wired | Yes | chio-http-session |
Struct found reads No when the crate the spec names declares no struct, enum, trait, or type alias of that name; Defined in then names the crate that does, or reads not found when none of the guard, kernel, and session crates does. At this pin: PostInvocationPipeline is defined in chio-kernel; PortableFilesystemRootsGuard is not found.
The session-aware guards (DataFlowGuard, BehavioralSequenceGuard) and the session-journal advisory guards (AnomalyAdvisoryGuard, DataTransferAdvisoryGuard) do not run until an operator wires a SessionJournal into the pipeline.
Compatibility and limits
- GUARDS.md is at version 1.0, dated 2026-04-14.
- Where the spec and the crates diverge, the crate is the behavior:
PostInvocationPipelinelives inchio-kernel;InternalNetworkGuardandAgentVelocityGuarddeny without an evidence entry of their own; the sanitization guard has no default level or action and replaces a failed built-in pattern with a catch-all; a WASM module missing an export fails at evaluation; the per-guardchio.yamlkeys in section 8.1 and the promotion-rule key in section 5.4 have no loader; and the journal guards read a snapshot rather than the getters section 7.6 names. - Conformance. The cross-language WASM conformance runner (
crates/guards/chio-wasm-guards/tests/conformance_runner.rs) loads the Rust, TypeScript, Python, and Go example guards and runs each against the fixtures undertests/conformance/fixtures/guard/(tool-gate.yamlandenriched-fields.yaml), checking that the verdicts agree. The escape tests undercrates/guards/chio-wasm-guards/tests/escape/cover undeclared imports, fuel exhaustion, oversize memory, deep recursion, table growth, host re-entry, malformed components, and signed-but-malicious modules.
Related
Kernel integration and operator guidance live in the Kernel pages.
- Guards: the concept and the catalog by family.
- Kernel: the Guard trait.
- Kernel: pipelines.
- Kernel: default pipeline.
- Kernel: fail-closed.
- Kernel: advisory.
- Kernel: WASM guards.
- Formal: theorem inventory.
- Security: threat model and TCB boundary.
- HTTP transport: HTTP-side evaluation pipeline.