Chio/Docs
LOGIN · JOIN

Rules Across a Session

Constrain the order and volume of an agent’s work with rules the kernel checks against every call recorded in the session journal.

Concept chio@5f63e6e

On this page

Session rules let you govern the order and volume of an agent’s work across hundreds of calls. You can hold the fixer to the way your team works an issue: read before writing, reproduce the failure before editing, stop a runaway search, and read no more than a set number of bytes. Each of these rules is about history. No single call breaks one, so a check that sees one call cannot express it.

The kernel records every governed call in the session’s journal. Session guards read that history before the next call is dispatched: sequence rules read the order of tool names, byte ceilings read cumulative totals, and advisory guards compare counts and byte totals with thresholds. A failed rule is an ordinary guard denial, so the call does not run and the kernel signs a deny receipt.

The kernel keeps the session’s history

The session journal is an append-only record of the governed calls in one session. Each entry records the tool, its server, the calling agent, the bytes read and written, the delegation depth, and whether the kernel allowed the call. Each entry also carries the SHA-256 hash of the entry before it, so a changed or missing entry breaks the chain.

Recent entries sit in a bounded ring. The fields that rules depend on are cumulative and survive eviction: the count for each recorded tool name, the current run of one tool and its length, and the session’s byte totals. A guard reads them in one snapshot under one lock, so it never sees a half-applied update.

Session guards fail closed. If the journal cannot be read, the guard returns an error and the kernel refuses the call. The journal holds one session in the kernel process’s memory; a restriction that must survive a restart belongs in persistent taint, which the flow store keeps outside the host process. Session-aware guards lists every field and cap.

Sequence rules read the order of tool names

A sequence rule constrains the next call by the tool names already in the journal. BehavioralSequenceGuard evaluates four kinds from one SequencePolicy. Each row is a rule you might write for the fixer on the pricing project:

RuleWhat the guard readsFor the fixer
required_first_toolWhether anything is recorded yetThe session opens with read_file.
required_predecessorsEvery tool name recorded so farwrite_file only after a run_command call that reproduces the failure.
max_consecutiveThe current run of one toolNo tool more than eight times in a row, such as a search loop.
forbidden_transitionsThe last tool and the requested oneNo fetch directly after read_file.

Configure sequence rules in Rust; HushSpec has no syntax for them. Build the session guards over the session’s journal and register them on the kernel:

rust
use std::collections::HashSet;
use std::sync::Arc;
use chio_guards::{
    BehavioralSequenceGuard, DataFlowConfig, DataFlowGuard, GuardPipeline, SequencePolicy,
};
use chio_http_session::SessionJournal;

let journal = Arc::new(SessionJournal::new(session_id));

let mut policy = SequencePolicy::default();
policy.required_first_tool = Some("read_file".to_string());
policy
    .required_predecessors
    .insert("write_file".to_string(), HashSet::from(["run_command".to_string()]));
policy.max_consecutive = Some(8);
policy
    .forbidden_transitions
    .push(("read_file".to_string(), "fetch".to_string()));

let mut pipeline = GuardPipeline::new();
pipeline.add(Box::new(BehavioralSequenceGuard::new(journal.clone(), policy)));
pipeline.add(Box::new(DataFlowGuard::new(
    journal.clone(),
    DataFlowConfig {
        max_bytes_read: Some(50 * 1024 * 1024),
        ..DataFlowConfig::default()
    },
)));

// Install on the existing mutable kernel.
kernel.add_guard(Box::new(pipeline));

The predecessor rule reads every name the session has recorded, and the journal counts denied calls as well as allowed ones. A run_command call anywhere earlier satisfies it, so the rule shows that a command ran before the edit. It does not see the command or its result. When a merge must wait for passing tests on the final code, the workflow that calls project.apply checks the test report; that check belongs to the application.

A transition rule sees one step

forbidden_transitions compares the requested tool with the immediately previous one. It sees tool names, never arguments or data. A plan of read, write, fetch therefore passes a read-then-fetch rule: the write_file call in between becomes the previous tool, and the pair no longer matches.

The hostile comment in pull request #421 asks for this plan: read a configuration file, then upload it to paste.example.net. A model facing a transition rule needs one harmless call between the read and the upload, such as writing a summary. Adding pairs does not close the gap, because the model chooses the call in between.

Two session journals, a proposed call after each, and each rule’s verdict on that callsession journalnext callEdit after reproducing the failureread_filesearchrun_commandwrite_filerequired_predecessorswrite_file needs run_commandno run_command recordeddenyRead settings, then post themsearchread_filewrite_filefetchforbidden_transitionsread_file → fetchlast tool is write_file: no matchallowinformation flow, at egresslabel joined at read_filedestination lacks clearancedenyclick run_command or write_file to remove or restore it
Two session journals, a proposed call after each, and each rule’s verdict on that callsession journalnext callEdit after reproducing the failure⋯searchrun_commandwrite_filerequired_predecessorswrite_file needs run_commandno run_command recordeddenyRead settings, then post them⋯read_filewrite_filefetchforbidden_transitionsread_file → fetchlast tool is write_file: no matchallowinformation flow, at egresslabel joined at read_filedestination lacks clearancedenytap run_command or write_file to remove or restore it

write_file: no run_command in the journal, so required_predecessors refuses it. fetch: the last tool is write_file, so the read_file to fetch transition does not match, but the label joined at read_file has no clearance at the destination, so information flow refuses it.

In Rust · behavioral_sequence.rs:10015 lines
if let Some(required) = self.policy.required_predecessors.get(tool_name) {
    for req in required {
        if !snapshot.tool_counts.contains_key(req) {
            return Ok(GuardDecision::deny(Vec::new()));
        }
    }
}
// … forbidden transitions read only the last recorded tool
if let Some(last_tool) = snapshot.current_streak_tool.as_deref() {
    for (from, to) in &self.policy.forbidden_transitions {
        if last_tool == from && tool_name == to {
            return Ok(GuardDecision::deny(Vec::new()));
        }
    }
}
Two sessions of the fixer on the pricing project. The information-flow line assumes the host classifies what read_file returns from deploy/settings.yaml and that paste.example.net has no clearance for that label; that check runs at egress, outside the sequence guard.

The property the operator wants is about data: what the session read may not reach that destination, however many calls come between. Information flow expresses it. The read joins an information label into the session’s retained state, the label stays joined through every later call, and the kernel refuses egress to a destination without clearance for that label. Information Flow Across Calls follows the label through delegation and restart.

Temporal detection catches the same shape after the fact. It correlates ordered security events, such as a credential access followed by an egress attempt in the same session within a window you set. Events in between do not reset the sequence, and a match records a finding with its evidence. Temporal detection defines the sequence and its window.

Volume rules count the whole session

DataFlowGuard sets cumulative ceilings on the bytes a session reads, writes, or moves in total. A 50 MiB read ceiling bounds what one session can pull from /workspace or the customers table, however the reads are split across calls. The guard compares the totals already recorded, so the call that crosses the ceiling completes and the guard refuses the next call in the session.

max_consecutive counts a run of one tool, and any other tool starts a new run. A loop that alternates search and read_file stays under it. The journal’s per-tool counts do not reset, and the anomaly advisory guard below reads them.

Some totals span more than one session. An agent that spreads a bulk read across delegated workers moves the reads into their sessions. The aggregate invocation budget counts calls across the whole delegation family, so splitting the work does not reset the total.

Observe a baseline before you enforce it

Some limits depend on what normal looks like for one agent’s work, and a ceiling set too low refuses legitimate sessions. Advisory guards record signals and leave the verdict to promotion rules, so you can see where thresholds fall before any of them denies a call.

Two advisory guards produce signals from the journal. AnomalyAdvisoryGuard signals when the requested tool’s count in the session reaches a threshold, or when the deepest delegation recorded in the session reaches another. DataTransferAdvisoryGuard signals when the session’s combined bytes reach a threshold, with higher severity at two and three times it.

A promotion rule names an advisory guard and a minimum severity. A signal at or above that severity denies the call, and the receipt’s evidence marks the signal as promoted. Guards shows promotion at work, and Advisory guards covers its configuration.

Continue from the session