Chio/Docs
LOGIN · JOIN

PlatformAuthoring & Portability

Kernel

Policy Compilation

The only path from written policy to guards the kernel runs: what a document materializes, and what the compiler refuses to build.

Two halves of one subject

HushSpec Policy Format owns the authoring model: fields, defaults, inheritance, and the validation reference. This page owns what happens after the document is written. Read that page to learn what to type; read this one to learn which guards your YAML produces, and what the compiler will not build.

One bridge, one output type

crates/guards/chio-policy/src/compiler.rs calls itself "the key bridge between HushSpec policies and Chio’s guard pipeline", and that is not decoration. Nothing else in the tree turns a rule block into a configured guard. If a policy field is not read by one of the five steps below, nothing downstream enforces it, whatever the schema accepts.

Compilation is shipped and load-bearing. The production caller is load_hushspec_policy in crates/platform/chio-control-plane/src/policy/loader.rs, selected by is_hushspec_format, a line scan of the file contents for a top-level hushspec key. It resolves, validates, calls compile_policy_with_source_and_approver_directory, and hands compiled.guards and compiled.post_invocation straight into the loaded policy as runtime state. Validation therefore runs twice on that path, once in the loader and once inside the compiler.

Every entry point funnels into one private function:

crates/guards/chio-policy/src/compiler.rs149-173rust
fn compile_policy_with_options(
    policy: &HushSpec,
    source_path: Option<&Path>,
    budget: &MemoryBudgetConfig,
    approver_directory: Option<&dyn ApproverDirectory>,
) -> Result<CompiledPolicy, CompileError> {
    ensure_compilable_policy(policy)?;

    let mut builder = PipelineBuilder::new();
    let mut post_invocation = PostInvocationPipeline::new();
    let source_dir = source_path.and_then(|path| path.parent());
    compile_rule_guards(policy, &mut builder, &mut post_invocation, budget)?;
    compile_detection_guards(policy, &mut builder, source_dir)?;
    compile_budget_guards(policy, &mut builder, budget)?;
    let default_scope = compile_scope(policy)?;
    let threshold_approval = compile_threshold_approval_requirement(policy, approver_directory)?;
    let (guards, guard_names) = builder.finish();
    Ok(CompiledPolicy {
        guards,
        post_invocation,
        default_scope,
        threshold_approval,
        guard_names,
    })
}

ensure_compilable_policy runs validate::validate and refuses the whole document if it produced any error, joining every message into one CompileError::Invalid. Warnings do not block: ValidationResult::is_valid is errors.is_empty(), so a policy that validates with "no rules configured" compiles to an empty pipeline rather than failing.


What compilation produces

CompiledPolicy fieldBuilt byWhat it is
guardsThree passes, in orderA GuardPipeline holding only the guards enabled blocks asked for. It starts empty and stays empty for a policy with no rules.
post_invocationcompile_rule_guardsA PostInvocationPipeline. Exactly one rule block writes to it: secret_patterns.
default_scopecompile_scopeA ChioScope derived from rules.tool_access and rules.human_in_loop alone.
threshold_approvalcompile_threshold_approval_requirementSome only when extensions.chio.human_in_loop.approvers is present. Absent that key it is None; present without an approver directory it is a compile error, never None.
guard_namesPipelineBuilderThe Guard::name() of every guard added, in insertion order. It exists because GuardPipeline does not expose its contents.

The five public entry points differ only in what they thread through. compile_policy_with_source supplies a source path, whose parent directory is used to resolve one thing and one thing only: the threat-intel pattern database. compile_policy_with_memory_budget passes a configured MemoryBudgetConfig so that a lowered velocity_bucket_cap bounds the velocity guards’ bucket maps instead of them silently using the compiled-in default. The approver-directory variants are the only way to compile a policy that declares threshold approvers at all.


Eighteen guard types, not twelve

The table at the top of compiler.rs announces twelve distinct guard types, and the doc comment on guard_names repeats the number. Twenty-two builder.add call sites across the three guard passes emit eighteen distinct types. The header omits six: velocity, computer_use, remote_desktop_channels, input_injection, browser_automation, and code_execution all compile to guards the table does not list. Whether the table predates them is not observable from the source; the omission is. The two tests named for twelve (compile_all_12_guard_types, compile_policy_emits_all_twelve_guard_types) are consistent, not contradictory: their fixture enables seven rule blocks, three detection blocks, and origin budgets, so twelve is what that document produces. The other six have tests of their own.

Policy keyGuard runtime nameCondition
rules.forbidden_pathsforbidden-pathEmpty patterns takes the guard’s built-in defaults and silently drops exceptions; a non-empty list carries both and a bad glob is a compile error.
rules.velocityvelocityOnly when max_invocations_per_window or max_spend_per_window is set. window_secs is clamped up to 1 and a burst_factor that is not finite and positive falls back to 1.0.
rules.velocityagent-velocityOnly when max_requests_per_agent or max_requests_per_session is set. Setting neither pair means the block compiles to nothing at all.
rules.shell_commandsshell-commandEmpty forbidden_patterns keeps the guard defaults.
rules.egressegress-allowlist, internal-networkOne block, two guards. Opting into egress control always adds the SSRF companion; the allowlist catches unknown domains, the companion catches raw private and metadata addresses. Empty allow and empty block takes the allowlist guard’s built-in lists. default does not travel: the guard is deny-by-default for any unlisted host whatever the block says.
rules.tool_accessmcp-toolCarries allow, block, default, and max_args_size, and nothing else: require_confirmation and the four assurance-tier and workload-identity fields reach the scope pass or nowhere. Also the only block that feeds the default scope.
rules.secret_patternssecret-leak plus a post-invocation hookSee below. This is the one block that compiles onto two pipelines.
rules.patch_integritypatch-integrityEvery field maps across; an invalid forbidden_patterns regex fails the compile.
rules.path_allowlistpath-allowlistread, write, patch become the three allow lists.
rules.computer_usecomputer-useobserve, guardrail, fail_closed map onto EnforcementMode.
rules.remote_desktop_channelsremote-desktop-side-channelFour toggles travel. Session share, printing, and transfer size are not modeled by HushSpec and stay at the guard’s defaults, which are permissive.
rules.input_injectioninput-injection-capabilityAllowed types plus require_postcondition_probe.
rules.browser_automationbrowser-automationDomain lists, verb list, credential detection, extra credential patterns.
rules.code_executioncode-executionLanguage allowlist, module denylist, network access, execution time, and an optional scan-byte cap.
extensions.detection.prompt_injectionprompt-injectionLevels become score thresholds: safe 0.1, suspicious 0.4, high 0.8, critical 1.0. max_scan_bytes is clamped up to 1, because zero would make the scanner a no-op that allows everything.
extensions.detection.jailbreakjailbreakblock_threshold is an integer percentage, capped at 100 and divided by 100; a value too large for a u32 becomes 0, not 100. max_input_bytes travels as the detector’s scan cap. warn_threshold is accepted and discarded: no field on the guard config carries it.
extensions.detection.threat_intelembedding-anomalyA missing or empty pattern_db is a validation error; a path that does not resolve, or a file that will not parse, is a compile error. similarity_threshold and top_k override the guard defaults.
extensions.origins.profiles[].budgetsagent-velocityThe tightest tool_calls across every profile becomes one per-agent ceiling over a fixed 60-second window.

Three consequences worth reading off that table. The three detection blocks default to on: the compiler tests enabled.unwrap_or(true), so a block present without an enabled key compiles its guard. Origin budgets collapse: the compiler picks a single minimum across all profiles rather than emitting a guard per origin, and an out-of-range tool_calls saturates to u32::MAX rather than failing. And guard_names is an insertion-order list, not a set. Nothing deduplicates across passes, so a document that sets both rules.velocity.max_requests_per_agent and an origin budget compiles two agent-velocity guards with different windows. That reading comes from the code; no in-tree test constructs the case.

Insertion order is deliberate in one place

The velocity guards are added between forbidden-path and shell-command so that rate-limit denials are observed before any shell semantics fire. velocity_guard_precedes_shell_guard in tests/velocity.rs asserts the relative position directly. Nothing else in the compiler makes an ordering claim, and the compiled set is not the default pipeline of seven guards: an empty document compiles to an empty pipeline.

secret_patterns compiles to two pipelines

One enabled secret_patterns block produces two things, on two different pipelines:

crates/guards/chio-policy/src/compiler/rules.rs125-146rust
// 5. secret_patterns -> SecretLeakGuard
//
// SecretLeakGuard handles the write path (detect secrets in outbound
// file writes) while the post-invocation SanitizerHook handles the read
// path (redact secrets in tool results before the agent sees them).
if let Some(sp) = &rules.secret_patterns {
    if sp.enabled {
        let config = chio_guards::secret_leak::SecretLeakConfig {
            enabled: true,
            skip_paths: sp.skip_paths.clone(),
            custom_patterns: compile_custom_secret_patterns(sp),
        };
        builder.add(
            SecretLeakGuard::with_config(config)
                .map_err(|error| CompileError::Invalid(error.to_string()))?,
        );
        post_invocation.add(Box::new(
            SanitizerHook::with_config(compile_output_sanitizer_config(sp))
                .map_err(|error| CompileError::Invalid(error.to_string()))?,
        ));
    }
}

secret-leak screens outbound writes before they happen. The SanitizerHook runs after a tool returns and redacts the result before the agent reads it. The two are configured differently, and the difference matters. SecretLeakConfig receives the pattern names, the raw patterns, and skip_paths. The patterns are additive: SecretLeakGuard::with_config compiles its own built-in list first and appends the policy’s. skip_paths is not additive, so an empty list replaces the guard’s default test-fixture exemptions with nothing.

compile_output_sanitizer_config starts from OutputSanitizerConfig::default() and overwrites one field, denylist.patterns, with the pattern strings alone. Names do not travel. Neither do skip_paths: the sanitizer has no path concept, so a path exempted on the write path is still redacted on the read path. The sanitizer keeps its own defaults for the secret, PII, and internal categories, for entropy detection (threshold 4.5, minimum token length 16), and for a max_input_bytes of 1,000,000. It therefore redacts more than the policy enumerated, and stops scanning past a megabyte of result. Neither block carries the severity or description a HushSpec secret pattern declares; both are dropped at the compiler boundary.

compile_secret_patterns_use_post_invocation_sanitizer_only pins the shape: one guard, one post-invocation hook, and a read_file result carrying an AWS key comes back as PostInvocationVerdict::Redact. The mechanics of that redaction are Response Sanitization.


Scope compilation fails closed

compile_scope answers a narrower question than the guard passes: which capability grants can the scope model express faithfully? Where it cannot express the policy’s intent, it emits nothing rather than something close.

tool_access shapeResulting scope
Absent, or the whole rules section absent, or enabled: falsePermissive: one grant, server *, tool *, Invoke, no constraints.
default: allow with a selective require_confirmation (any non-* pattern, on tool_access or on an enabled human_in_loop)Empty. Selective confirmation cannot ride on a wildcard grant, so it stays in the policy evaluator instead of being widened.
default: allow, no lists, no caps, no approvalPermissive wildcard.
default: allow with empty allow and block, plus max_args_size, a runtime assurance tier, a * confirmation, or approval constraintsConstrained wildcard: one */* grant carrying those constraints.
default: allow with a non-empty allow or block listEmpty. Both wildcard paths require the lists to be empty, and no branch turns an allow-by-default list into grants, so the scope silently comes back with nothing in it.
default: block with an empty allowEmpty.
Any require_workload_identity or prefer_workload_identityEmpty. The scope model has no encoding for workload identity, so the compiler declines to guess.
default: block with an allow listOne grant per allow entry that does not overlap the block list. If every entry overlaps, the scope is empty.

Overlap is not string equality. compiler/patterns.rs treats * on either side as overlap and two wildcard-free strings as overlapping only when they are equal. Otherwise it compiles each glob to a regex through regex_safety::compile_generated_policy_regex, tries both match directions, then falls back to comparing the literal prefix each pattern has before its first * or ?. That fallback over-approximates deliberately: two patterns whose prefixes nest are treated as overlapping whether or not they share a match. So payments.* in allow and payments.charge in require_confirmation overlap, and the compiled grant carries Constraint::RequireApprovalAbove { threshold_units: 0 }. An invalid glob here is a compile error, not a silent non-match.

Threshold approvers are stricter still. extensions.chio.human_in_loop.approvers requires an ApproverDirectory; compiling without one is "threshold approvers require an authenticated approver directory". Only the two approver-directory entry points supply one, so plain compile_policy can never compile a document that declares approvers. The directory the control plane supplies is not a service: HexApproverDirectory in loader.rs accepts an identifier only if it is a canonical hex public key, and stamps every resolution with the constant version self-authenticating-public-key-v1.

Every approver must resolve to the identifier it was asked for, and all of them must come from one non-empty directory version, or the compile fails. ThresholdApprovalRequirement::new then adds its own refusals: the quorum n must be between 1 and the approver count, identifiers and public keys must each be unique, the set is capped at MAX_THRESHOLD_APPROVAL_TOKENS = 32, and the timeout must be between 1 and MAX_THRESHOLD_APPROVAL_TIMEOUT_SECONDS = 3600. It defaults to DEFAULT_THRESHOLD_APPROVAL_TIMEOUT_SECONDS = 900. The requirement the compiler mints binds compute_policy_hash(policy), but the control-plane loader immediately rebuilds it against the loaded policy’s runtime_hash, so the hash the compiler binds is not the hash the runtime enforces.


extends resolution under hard limits

Resolution happens before any of the above. resolve.rs walks the extends chain, merges parents under children, and returns one flat document. It does not validate and does not compile. Two constants bound it:

crates/guards/chio-policy/src/resolve.rs11-12rust
pub const DEFAULT_MAX_POLICY_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
pub const DEFAULT_MAX_EXTENDS_DEPTH: usize = 32;
  • Depth counts documents, not hops. The stack is seeded with the starting document and the check is stack.len() >= max_extends_depth before a parent is loaded, so a depth of 1 rejects a child with a single parent. Both limits are rejected outright at zero.
  • Size is checked twice. Once against fs::metadata before the file is opened, then again on a take(max + 1) read that catches a file which grew between the two. Each check is its own ResolveError::Limit.
  • Cycles report the chain. Sources are whatever identifier the loader returns, which for the filesystem loader is a canonicalized path, and a repeat produces Cycle with the loop joined by ->, for example a -> b -> a.
  • Nothing is fetched over the network. create_composite_loader rejects an http:// or https:// reference with "HTTP-based policy loading is not supported in chio-policy", but nothing outside its own test uses it. The control plane and the CLI both call resolve_from_path, which wires the filesystem loader directly, so a URL reference is treated as a relative path and fails canonicalization with ResolveError::Read. Either way there is no request, and no timeout to tune.
  • References are not confined. A relative extends resolves against the referring document’s directory and an absolute one is taken as written. There is no policy root, so a document can extend any file the process can read.

Merging depends on merge_strategy, and the difference is not only depth. replace discards the parent document outright and keeps the child. merge and deep_merge (the default) merge rules identically, per block and not per field: a child that declares egress replaces the parent’s whole egress block. They differ only in how deeply extensions combine. The field semantics are documented on HushSpec Policy Format.


The policy decision receipt

evaluate_audited wraps a single policy evaluation with an Instant, a SHA-256 over the document, and a structured DecisionReceipt. It carries two versions on purpose: hushspec_version is the binary’s HUSHSPEC_VERSION constant, while policy.version is a copy of the document’s own hushspec: field.

crates/guards/chio-policy/src/receipt.rs129-134rust
pub fn compute_policy_hash(spec: &HushSpec) -> String {
    let json = serde_json::to_string(spec).unwrap_or_default();
    let mut hasher = Sha256::new();
    hasher.update(json.as_bytes());
    format!("{:x}", hasher.finalize())
}

Read the unwrap_or_default() literally: a serialization failure hashes the empty string rather than raising. Nothing in the tree is known to reach that branch, but the hash is not evidence that serialization succeeded.

AuditConfig has two flags, both defaulting true, and they do different things. enabled: false skips both the timer and the hash: duration comes back 0 and content_hash comes back an empty string, while name and version survive. redact_content sets one boolean, content_redacted, which is true only when the flag is set and the action carried content. Since ActionSummary has no content field in either case, the flag records that content was present, not that it was carried and stripped.

Two different receipts

This is the policy layer’s own record and it is not the kernel receipt log. evaluate_audited and DecisionReceipt are a library API with no caller in the tree outside their own tests. compute_policy_hash has one caller outside receipt.rs: compiler.rs passes its digest into ThresholdApprovalRequirement::new, so it binds the compiled threshold-approval requirement. For the signed, persisted receipt the kernel writes on every governed action, read Receipts & Audit.

Guarantees and limits

StatusClaimEvidence
ShippedEighteen distinct guard types compile from one document. A document with no enabled block compiles to an empty pipeline, not a default one.compiler/rules.rs, compiler/detection.rs, compiler/budgets.rs; compile_empty_policy_yields_empty_pipeline
ShippedA document that fails validation cannot compile: the guard passes never run.ensure_compilable_policy, the first statement of compile_policy_with_options
Shippedsecret_patterns compiles onto both the guard pipeline and the post-invocation pipeline, and it is the only block that does.compile_secret_patterns_use_post_invocation_sanitizer_only
Proved by testCompilation is stable across a YAML round trip: parse, serialize, reparse, recompile yields the same sorted guard names and the same canonicalized scope. The same target asserts that any input it reaches that validates also compiles, over a generator covering five rule blocks and a six-file seed corpus. That is not a general theorem: a policy naming a threat-intel database that is not on disk, or declaring threshold approvers, validates and then fails to compile.fuzz/fuzz_targets/policy_parse_compile.rs
Proved by testResolution rejects an oversized document and an over-deep extends chain, and reports a cycle as the chain that closed it.resolve_with_loader_detects_cycles_in_extends_chain
LimitCompilation reads the filesystem. An enabled threat_intel block opens and parses its pattern database during the compile, resolved relative to the policy’s directory only when a source path was supplied, and relative to the working directory otherwise.threat_intel_guard_from, resolve_policy_asset_path
LimitFields the schema accepts and no guard receives: jailbreak.warn_threshold; egress.default, since the compiled allowlist guard is deny-by-default regardless; the severity and description on every secret pattern; forbidden_paths.exceptions when patterns is empty; and the session-share, printing, and transfer-size settings the remote-desktop guard supports but HushSpec does not model.jailbreak_config_from, EgressAllowlistGuard::with_lists, compile_custom_secret_patterns, compile_rule_guards, compile_remote_desktop_rule
LimitA tool_access block with default: allow and a non-empty allow or block list compiles to an empty default scope. The mcp-tool guard still enforces the lists; nothing is issued as a capability grant.compile_scope, the fall-through to ChioScope::default()
LimitOrigin budgets collapse to one ceiling. The minimum tool_calls across all profiles becomes a single per-agent limit over a hardcoded 60-second window; per-origin granularity is lost.compile_budget_guards, comment and code
UnsupportedRemote extends references. No path in chio-policy makes an HTTP request. The named refusal lives in create_composite_loader, which has no caller outside its own test; production resolution reports a URL reference as an unreadable path instead.create_composite_loader
Stale documentationThe guard-coverage table in compiler.rs and the doc comment on guard_names both say twelve guard types. The code emits eighteen.The twenty-two builder.add call sites across the three guard passes, and Guard::name() in chio-guards

Next steps

  • HushSpec Policy Format · the authoring model this page compiles: every field, its default, and what validation rejects
  • Response Sanitization · what the post-invocation hook that secret_patterns installs actually does to a tool result
  • Default Pipeline · the seven guards a node runs without a policy, and how a compiled pipeline differs
  • Capabilities · what the default scope becomes once the control plane turns grants into issued capabilities
  • Testing Guards & Policies · the dry-run interpreter that reads the same document without building a pipeline