Chio/Docs
LOGIN · JOIN

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

rust
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).

CategoryStatePhaseBlocking
Stateless deterministicNonePre-invocationYes
Session-aware deterministicSession journalPre-invocationYes
Post-invocation hooksTool responsePost-invocationYes
Advisory signalsSession journal (optional)Pre-invocationNo (unless promoted)
WASM custom guardsSandboxed runtimePre-invocationConfigurable

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):

  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.

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 Allow is 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:

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,
}
VerdictEffect in GuardPipeline
AllowThe 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).
DenyThe pipeline returns at once with the evidence collected so far plus its own deny entry (lines 79 to 88).
PendingApprovalSticky. 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.

VariantTagFieldsDescription
Deterministic"deterministic"guard_name, verdict (bool), detailsResult from a standard guard
Advisory"advisory"All AdvisorySignal fieldsNon-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):

ClassRangeRationale
RFC 1918 (Class A)10.0.0.0/8Private networks
RFC 1918 (Class B)172.16.0.0/12Private networks
RFC 1918 (Class C)192.168.0.0/16Private networks
Loopback (IPv4)127.0.0.0/8Host-local services
Loopback (IPv6)::1Host-local services
Link-local (IPv4)169.254.0.0/16Auto-configured addresses
Link-local (IPv6)fe80::/10Neighbor discovery scope
Unique local (IPv6)fc00::/7Private IPv6 ranges
Cloud metadata169.254.169.254, metadata.google.internal, metadata.azure.comCloud instance credentials
Kuberneteskubernetes.default.svc, kubernetes.defaultCluster-internal APIs
Broadcast255.255.255.255Network broadcast
Current network0.0.0.0/8Ambiguous 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).

OptionTypeDefaultDescription
extra_blocked_hostsstring[][]Additional hostnames to block beyond the built-in set (lines 27 and 36)
dns_rebinding_detectionbooltrueEnable 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:

ReasonTrigger
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

Any IP parse error, ambiguous address, or unexpected format MUST result in denial (section 2.1). A request whose action the host cannot extract denies before any host check (lines 103 to 106). 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 (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).

BucketKeyPurpose
Per-agentagent_idCross-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).

OptionTypeDefaultDescription
max_requests_per_agentu32 or nullnull (unlimited)Maximum requests per agent per window
max_requests_per_sessionu32 or nullnull (unlimited)Maximum requests per session per window
window_secsu6460Window duration in seconds
burst_factorf641.0Bucket 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).

OptionTypeDefaultDescription
max_bytes_readu64 or nullnull (unlimited)Maximum cumulative bytes read per session
max_bytes_writtenu64 or nullnull (unlimited)Maximum cumulative bytes written per session
max_bytes_totalu64 or nullnull (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):

ConstraintDescription
Required predecessorsTool X MUST NOT run unless tools Y and Z have been invoked earlier in the session
Forbidden transitionsTool X MUST NOT run immediately after tool Y
Max consecutiveThe same tool MUST NOT run more than N times consecutively
Required first toolThe first tool in a session MUST match a specified name
OptionTypeDefaultDescription
required_predecessorsHashMap<String, HashSet<String>>emptyTool name to the set of tools that must have run before it
forbidden_transitionsVec<(String, String)>empty(from_tool, to_tool) pairs that are forbidden
max_consecutiveOption<u32>None (unlimited)Maximum consecutive invocations of the same tool
required_first_toolOption<String>NoneTool 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):

VerdictEffect
AllowResponse 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 Block from any hook MUST stop the pipeline immediately. No subsequent hooks run; the outcome carries the evidence collected so far (lines 328 to 334).
  • A Redact replaces the response for all subsequent hooks. Multiple Redact hooks compose sequentially (lines 335 to 337).
  • Escalate messages are collected throughout the pipeline and joined with ; in the final verdict (lines 338 to 340 and 346 to 347).
  • The final verdict is Redact when the response changed, Escalate when any hook escalated, and otherwise Allow (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.

PatternExampleSensitivityRedaction
SSN123-45-6789High[SSN REDACTED]
emailuser@example.comMedium[EMAIL REDACTED]
phone(555) 123-4567Low[PHONE REDACTED]
credit-card4111-1111-1111-1111High[CARD REDACTED]
date-of-birth1990-01-15 or 01/15/1990Low[DATE REDACTED]
MRNMRN: 123456789High[MRN REDACTED]
ICD-10J18.9, E11Medium[ICD REDACTED]

Sensitivity levels (SensitivityLevel, lines 13 to 20):

LevelMeaning
LowMay produce false positives (phone numbers, dates)
MediumLikely PII (emails, medical codes)
HighDefinite 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):

ActionBehavior
BlockDeny the response entirely if any pattern matches
RedactReplace matching patterns with their redaction strings and allow the response

Configuration (section 4.2):

OptionTypeDefaultDescription
min_levelSensitivityLevelLowMinimum sensitivity level to trigger
actionSanitizationActionBlockAction to take on a match
custom_patternsSensitivePattern[][]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_response returns ScanResult::Clean, ScanResult::Blocked(findings), or ScanResult::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.

FieldTypeDescription
guard_namestringName of the advisory guard that produced the signal
descriptionstringHuman-readable observation
severityAdvisorySeveritySeverity classification
metadataobject or absentStructured metadata about the observation; serde omits it when absent (line 53)
promotedbooleanWhether 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

LevelOrdinalMeaning
Info0Informational observation, no action needed
Low1Worth monitoring over time
Medium2May warrant investigation
High3Likely needs operator attention
Critical4Strong 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):

  1. 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.
  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 (lines 249 to 252 and 266 to 267).
  5. If no signal is promoted, the pipeline returns Allow with the signal evidence (lines 268 to 270).
  6. All collected signals, promoted or not, are stored for evidence export through last_signals() and last_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):

yaml
# PromotionPolicy as serde serializes it
rules:
  - guard_name: anomaly-advisory
    min_severity: high
  - guard_name: data-transfer-advisory
    min_severity: critical
PromotionRule fieldTypeDescription
guard_namestringExact match on the advisory guard's name
min_severityAdvisorySeverityMinimum 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).

ConditionSeverityDescription
Tool invoked >= threshold timesMediumTool X invoked N times (threshold: T); metadata tool_name, count, and threshold (lines 329 to 346)
Tool invoked >= 2x thresholdHighElevated severity for sustained repetition (line 336)
Delegation depth >= thresholdHighDelegation depth D exceeds threshold T (lines 354 to 366)
OptionTypeDefaultDescription
invocation_thresholdu64(required)Per-tool invocation count that triggers a signal (line 293)
depth_thresholdu32(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).

ConditionSeverityDescription
Total bytes >= thresholdMediumCumulative transfer at or above the threshold
Total bytes >= 2x thresholdHighElevated severity
Total bytes >= 3x thresholdCriticalCritical 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):

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

Core-module invocation protocol (section 6.1; wasmtime_backend.rs lines 631 to 697):

  1. The host serializes a GuardRequest as JSON (line 632).
  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 (lines 640 to 663). Without the export, the host writes at offset 0 (line 665).
  3. The host writes the JSON bytes at the selected offset and calls evaluate(request_offset, json_length) (lines 676 to 680).
  4. The guest reads the request, evaluates it, and returns a verdict code.
CodeMeaning
0Allow (VERDICT_ALLOW, abi.rs line 15)
1Deny (VERDICT_DENY, line 18)
Any other i32Error. 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.

FieldTypeDescription
tool_namestringTool being invoked
server_idstringServer hosting the tool
agent_idstringAgent making the request
argumentsobjectTool arguments (opaque JSON)
scopesstring[]Granted scope names, formatted as "server_id:tool_name"
action_typestring or absentHost-extracted action class
extracted_pathstring or absentNormalized filesystem path for filesystem actions
extracted_targetstring or absentTarget domain for network egress actions
filesystem_rootsstring[] or absentSession-scoped filesystem roots
matched_grant_indexinteger or absentIndex 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

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. 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):

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

WasmGuardConfig (crates/guards/chio-wasm-guards/src/config.rs lines 20 to 51, defaults at lines 53 to 67):

OptionTypeDefaultDescription
namestring(required)Human-readable name, used in receipts and logs
pathstring(required)Filesystem path to the .wasm module
fuel_limitu6410,000,000Maximum fuel units per invocation (lines 27 to 30)
priorityu321000Evaluation order; lower values run first (lines 32 to 35)
advisoryboolfalseIf true, a denial or error is logged and not enforced (lines 37 to 40)
max_memory_bytesusize16 MiBMaximum guest linear memory (lines 42 to 45 and 61 to 63)
max_module_sizeusize10 MiBMaximum 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 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 (wasmtime_backend.rs lines 607 to 611) and rejects an oversized module before compilation with ModuleTooLarge (lines 564 to 570).
  • Allowlisted host callbacks: a core module may import chio.log, chio.get_config, and chio.get_time_unix_secs (KNOWN_HOST_FUNCTIONS, lines 844 to 845). 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 (wit/chio-guard/world.wit). load_module rejects any import outside the chio namespace with ImportViolation (lines 575 to 583), and the policy loader rejects a chio import the guard entry did not declare in capabilities with LoadError::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.rs lines 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:

FieldTypeDescription
sequenceu64Monotonically increasing sequence number (0-based)
prev_hashstringSHA-256 hex hash of the previous entry (zero hash for the first entry)
entry_hashstringSHA-256 hex hash of this entry's canonical fields
timestamp_secsu64Unix timestamp (seconds) when the entry was recorded
tool_namestringTool that was invoked
server_idstringServer that hosted the tool
agent_idstringAgent that made the invocation
bytes_readu64Bytes read during this invocation
bytes_writtenu64Bytes written during this invocation
delegation_depthu32Delegation depth at the time of invocation
allowedbooleanWhether 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.

  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, 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:

  1. Each entry's prev_hash matches the preceding entry's entry_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).
  2. Each entry's entry_hash matches 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:

GuardSnapshot fieldsSource
DataFlowGuarddata_flowdata_flow.rs lines 58 to 61
BehavioralSequenceGuardcurrent_streak_tool, tool_counts, tool_sequence, and the streak lengthbehavioral_sequence.rs lines 66 to 134
AnomalyAdvisoryGuardtool_counts, data_flow.max_delegation_depthadvisory.rs lines 321 to 325 and 354
DataTransferAdvisoryGuarddata_flowadvisory.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:

yaml
guards:
  allow_advisory_promotion: true
  required:
    - internal-network
    - response-sanitization

allow_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.

The default guard pipeline: forbidden-path, shell-command, egress-allowlist, path-allowlist, mcp-tool, secret-leak, patch-integrity, evaluated in that ordercall1. ForbiddenPathGuard answers to the runtime name forbidden-pathforbidden-pathForbiddenPathGuard2. ShellCommandGuard answers to the runtime name shell-commandshell-commandShellCommandGuard3. EgressAllowlistGuard answers to the runtime name egress-allowlistegress-allowlistEgressAllowlistGuard4. PathAllowlistGuard answers to the runtime name path-allowlistpath-allowlistPathAllowlistGuard5. McpToolGuard answers to the runtime name mcp-toolmcp-toolMcpToolGuard6. SecretLeakGuard answers to the runtime name secret-leaksecret-leakSecretLeakGuard7. PatchIntegrityGuard answers to the runtime name patch-integritypatch-integrityPatchIntegrityGuardfirst Deny ends evaluation; an Err is a Deny7 guards, registered in this order by GuardPipeline::default_pipeline(); Default::default() is empty
The 7 guards GuardPipeline::default_pipeline() registers, in registration order. Post-invocation hooks, advisory guards, and operator-wired guards are outside it.
sourcecrates/guards/chio-guards/src/pipeline.rs:39-49at fe56570

The 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).

GuardCrate (spec)ImplementationDefault control-plane profileStruct foundDefined in
InternalNetworkGuardchio-guardsFullInstalled by default runtime profileYeschio-guards
AgentVelocityGuardchio-guardsFullInstalled by default runtime profileYeschio-guards
DataFlowGuardchio-guardsFullOperator-wired (requires SessionJournal)Yeschio-guards
BehavioralSequenceGuardchio-guardsFullOperator-wired (requires SessionJournal)Yeschio-guards
ResponseSanitizationGuardchio-guardsFullInstalled by default runtime profile through SanitizerHookYeschio-guards
PostInvocationPipelinechio-guardsFullInstalled by default runtime profileNochio-kernel
AdvisoryPipelinechio-guardsFullInstalled by default runtime profile (session-journal advisory guards remain operator-wired)Yeschio-guards
AnomalyAdvisoryGuardchio-guardsFullOperator-wired (requires SessionJournal)Yeschio-guards
DataTransferAdvisoryGuardchio-guardsFullOperator-wired (requires SessionJournal)Yeschio-guards
PortableFilesystemRootsGuardchio-guardsFullOperator-wired (filesystem-scoped)Nonot found
WasmGuardchio-wasm-guardsFullOperator-wiredYeschio-wasm-guards
WasmGuardRuntimechio-wasm-guardsFullOperator-wiredYeschio-wasm-guards
SessionJournalchio-http-sessionFullOperator-wiredYeschio-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: PostInvocationPipeline lives in chio-kernel; InternalNetworkGuard and AgentVelocityGuard deny 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-guard chio.yaml keys 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 under tests/conformance/fixtures/guard/ (tool-gate.yaml and enriched-fields.yaml), checking that the verdicts agree. The escape tests under crates/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.

Kernel integration and operator guidance live in the Kernel pages.