Policy Compilation
Compile a HushSpec document, inspect the scope, guards, and output hook it produces, and see where the host installs each.
On this page
One HushSpec document configures three runtime controls. The compiler turns it into the capability scope a host signs authority from, the request guards the kernel runs before dispatch, and the output hook that processes a tool result before the agent receives it.
chio policy explain prints what a document compiles to without writing Rust, and the policy inspector, a Rust program built on the same compiler, prints the complete serialized result. Write rules with HushSpec Policy Format; run the same controls against a tool in the document application.
One document compiles into three controls the host installs
The compiler validates the resolved document, then builds each control from the rule blocks that configure it.
The host signs authority from the compiled scope
tool_access compiles into default_scope: one grant to invoke quote on server *, constrained by MaxArgsSize(4096). The scope is unsigned. The host issues a capability token from it, and the agent presents that token with each request. Capability reference.
Compilation builds the controls in memory. The host installs the request pipeline and the output pipeline, and signs a capability token from the scope, before an agent can use any of them. The control-plane loader installs both pipelines, builds the default capability configuration, and binds the policy's identity for runtime use. Compiler output describes constructed controls; the signed kernel receipt is the record that a call was decided.
Validation errors stop the build; warnings do not. A document with no configured rule compiles to no request guards and one wildcard default grant, so an explicit tool_access block is how a policy limits authority.
One command prints what a document compiles to
Download the complete policy-inspector project and unpack it anywhere. base.yaml allows quote with at most 4,096 bytes of serialized arguments, and policy.yaml extends it with a custom secret pattern. chio policy explain prints what a document compiles to without writing Rust:
chio policy explain policies/policy.yamlpolicy policies/policy.yaml extends policies/base.yaml
grants quote on server * max_args_size 4096
guards 1 mcp-tool
2 secret-leak
output 1 hook
approvals noneThe policy-inspector project below calls the same compiler from the library and prints the complete serialized scope. It is the starting point for a host that inspects policies in its own code.
The project carries its source, policies, tests, and the Cargo lockfile. Its Cargo.toml takes the chio-policy crate as a git dependency pinned to one public revision.
hushspec: "0.1.0"
name: document-policy
rules:
tool_access:
enabled: true
default: block
allow: [quote]
max_args_size: 4096hushspec: "0.1.0"
extends: base.yaml
rules:
secret_patterns:
enabled: true
patterns:
- name: project-token
pattern: 'DEMO_[A-Z0-9]{8}'
severity: errorcargo run --locked --quiet -- policies/policy.yaml{
"default_scope": {
"grants": [
{
"constraints": [
{
"type": "max_args_size",
"value": 4096
}
],
"operations": [
"invoke"
],
"server_id": "*",
"tool_name": "quote"
}
]
},
"guard_names": [
"mcp-tool",
"secret-leak"
],
"output_hooks": 1,
"threshold_approval": false
}You should see two request guards, mcp-tool and secret-leak, one output hook, and one default grant: invoke quote on server * with a max_args_size of 4096. The child inherited the parent's tool_access block whole. The output is the complete serialized default_scope, with the guard names and hook count beside it. Constrain the server when you issue authority for your own application.
A stricter child narrows the grant and leaves the guards alone
attested.yaml requires an attested runtime and lowers the argument limit to 2,048 bytes. It repeats the allowlist, because a child's tool_access block replaces its parent's whole block.
hushspec: "0.1.0"
extends: policy.yaml
rules:
tool_access:
enabled: true
default: block
allow: [quote]
max_args_size: 2048
require_runtime_assurance_tier: attestedcargo run --locked --quiet -- policies/attested.yaml{
"default_scope": {
"grants": [
{
"constraints": [
{
"type": "max_args_size",
"value": 2048
},
{
"type": "minimum_runtime_assurance",
"value": "attested"
}
],
"operations": [
"invoke"
],
"server_id": "*",
"tool_name": "quote"
}
]
},
"guard_names": [
"mcp-tool",
"secret-leak"
],
"output_hooks": 1,
"threshold_approval": false
}You should see the same guard names and hook count. The grant now carries two constraints, MaxArgsSize at 2048 and MinimumRuntimeAssurance at attested. The inspector reports the requirement only: it supplies no attestation evidence and evaluates no request.
An empty allowlist removes the grant on purpose. blocked.yaml keeps default: block and allows nothing.
hushspec: "0.1.0"
extends: policy.yaml
rules:
tool_access:
enabled: true
default: block
allow: []cargo run --locked --quiet -- policies/blocked.yaml{
"default_scope": {},
"guard_names": [
"mcp-tool",
"secret-leak"
],
"output_hooks": 1,
"threshold_approval": false
}You should see default_scope serialized as {}. It supplies no default tool authority, and its serializer omits the empty grants list. Both guards still compile. An empty pipeline and an empty scope are different results, so check for each.
The scope compiler emits only the grants it can represent
Enabled tool_access rules and applicable human_in_loop approval settings become grants. A combination the scope cannot express produces no default grant, so a successful compile can return an empty scope, which is a result to inspect and never a parse error.
- No
tool_accessblock, or a disabled one. One wildcard server and tool grant forInvoke, with no constraints. Omittingruleshas the same result. default: blockwith an allowlist. One wildcard-serverInvokegrant per allowed tool pattern that overlaps no blocked pattern. An empty allowlist gives no grants.default: allow. With both lists empty, one wildcard grant that can carrymax_args_size, a required assurance tier, all-tool confirmation, or an approval threshold. A nonemptyalloworblocklist, or a confirmation that covers only some tools, gives no grants, because a requirement on some tools cannot widen into a wildcard grant.- Workload identity. An enabled block with
require_workload_identityorprefer_workload_identitygives no grants from this path; the scope does not encode identity conditions.
Constraints map one to one. max_args_size becomes MaxArgsSize, and require_runtime_assurance_tier becomes MinimumRuntimeAssurance; a preferred tier adds nothing. A matching require_confirmation becomes RequireApprovalAbove with threshold 0. Otherwise an enabled human_in_loop.approve_above supplies the threshold.
Overlap resolves conservatively. Allowing payments.* while requiring confirmation for payments.charge attaches the approval requirement to the compiled payments.* grant. An allow pattern that overlaps a blocked pattern is dropped, and an invalid pattern fails the compile.
Read more in Capability reference.
A failed compile stops before any control exists
The project includes a policy that declares an unsupported HushSpec version, so you can watch a failure without breaking an integration.
hushspec: "9.9.9"
rules:
tool_access:
enabled: true
default: block
allow: [quote]cargo run --locked --quiet -- policies/invalid.yamlCould not compile policy: invalid policy: HushSpec validation failed: unsupported hushspec version: 9.9.9
You should see the validation error and exit status 1. An unreadable pattern database, a regex or glob that cannot build, and threshold approvers without a directory stop the compile the same way; Compilation failures gives the action for each.
A compile can also succeed while a setting has no mapping on this runtime path: egress.default, jailbreak.warn_threshold, and a secret pattern's severity and description are examples. Schema acceptance alone establishes no enforcement, so test the exact controls your deployment relies on through your configured application.
Read more in Testing Guards & Policies.
Compiled fields, rule mappings, and limits
Try rules.egress, default_scope, or max_extends_depth. Each entry has its own link.
CompiledPolicy fields
| Field | Holds | Host action |
|---|---|---|
guards | The request GuardPipeline | Install before dispatch |
post_invocation | The output pipeline | Install after the tool |
default_scope | Generated tool grants and constraints | Sign capability authority from it |
threshold_ | An optional policy-owned threshold requirement | Install it; approvers need a directory |
guard_names | Ordered runtime guard names | Inspect; a name can repeat |
Rule, detection, and budget passes add guards to guards in that order. In this compiler, secret_patterns adds its SanitizerHook to post_invocation. default_scope is unsigned. Configured threshold approvers need an authenticated directory at compilation. guard_names can hold the same name twice when separate passes add the same guard type.
Compiler orchestration source
The compiler lives in crates/guards/chio-policy/src/compiler.rs, with the rule passes in compiler/rules.rs and scope generation in compiler/scope.rs.
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,
})
}Policy rule to runtime guard
Present, enabled rule blocks construct their guards; the three detection blocks default to enabled when enabled is omitted. The pipeline holds only what this compile adds and does not inherit the default pipeline.
| Policy key | Guard runtime name | Condition |
|---|---|---|
rules. | forbidden-path | Empty patterns uses built-in defaults without exceptions; a non-empty list carries both, and a bad glob fails the compile. |
rules.velocity | velocity | Only with max_ or max_. window_secs clamps up to 1. |
rules.velocity | agent-velocity | Only with max_ or max_; with neither, no guard. |
rules. | shell-command | Empty forbidden_ keeps the guard defaults. |
rules.egress | egress-, internal- | One block, two guards: the allowlist catches unknown domains, the SSRF companion raw private and metadata addresses. |
rules. | mcp-tool | Carries allow, block, default, and max_args_size. Approval and assurance constraints compile into the scope. |
rules. | secret-leak plus a post-invocation hook | The one block that compiles onto two pipelines. |
rules. | patch-integrity | Every field maps across; an invalid forbidden_ regex fails the compile. |
rules. | path-allowlist | read, write, patch become the three allow lists. |
rules. | computer-use | observe, guardrail, fail_closed map onto EnforcementMode. |
rules. | remote- | Four toggles travel. Session share, printing, and transfer size stay at the guard’s permissive defaults. |
rules. | input- | Allowed types plus require_. |
rules. | browser- | Domain lists, verb list, credential detection, extra credential patterns. |
rules. | code-execution | Language allowlist, module denylist, network access, execution time, optional scan-byte cap. |
extensions. | prompt- | Levels become score thresholds: safe 0.1, suspicious 0.4, high 0.8, critical 1.0. max_scan_bytes clamps up to 1. |
extensions. | jailbreak | block_threshold is an integer percentage, capped at 100 and divided by 100. max_input_bytes becomes the scan cap. |
extensions. | embedding- | A missing or empty pattern_db fails validation; an unresolvable or unparsable file fails the compile. |
extensions. | agent-velocity | The tightest tool_calls across profiles becomes one per-agent ceiling over a fixed 60-second window. |
A burst_factor that is not finite and positive falls back to 1.0. Empty egress allow and block lists take the allowlist guard's built-in lists, and egress.default does not travel: the guard refuses any unlisted host whatever the block says. Workload-identity settings on tool_access produce no default grants.
For prompt_injection, a max_scan_bytes of zero would make the scanner a no-op that allows everything, hence the clamp. For jailbreak, a conversion overflow to u32 uses 0, and warn_threshold has no guard mapping. For threat_intel, similarity_threshold and top_k override the guard defaults.
Velocity guards sit after forbidden-path and before shell-command. Origin budgets take the smallest tool_calls limit across profiles, saturating values above u32::MAX. Combined with rules.velocity, the pipeline can hold two agent-velocity entries, and guard_names preserves both.
Secret pattern compilation
secret-leak checks supported outbound writes. SanitizerHook processes tool results before delivery to the agent. The custom pattern in the example reaches both, with different configurations.
| Setting | Write guard | Output hook |
|---|---|---|
Pattern text | Added to the built-in secret patterns | Replaces the denylist pattern list; other detectors keep defaults |
Pattern name | Preserved for the custom pattern | Not passed |
skip_paths | The exemption list; empty removes the default exemptions | Not used |
severity, description | Not mapped | Not mapped |
A write exemption does not exempt content in a returned result. The output hook keeps the sanitizer's built-in secret, PII, internal-data, and entropy detection defaults and its 1,000,000-byte scan cap, so it can redact patterns beyond your custom list. Response Sanitization covers result handling and scan limits.
Source for the two secret-check pipelines
// 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()))?,
));
}
}Compiler entry points
| Function | Source path | Approver directory | Memory budget |
|---|---|---|---|
compile_policy | No | No | Process default |
compile_ | Optional | No | Process default |
compile_ | Yes | No | Configured Memory |
compile_ | No | Authenticated lookup | Process default |
compile_ | Yes | Authenticated lookup | Process default |
The source path gives relative auxiliary assets a base directory. The inspector calls compile_policy_with_source; the control-plane loader calls compile_policy_with_source_and_approver_directory. MemoryBudgetConfig includes velocity_bucket_cap.
Threshold approvers must resolve to the requested identities from one nonempty directory version. The requirement checks quorum, unique identifiers and keys, at most 32 approvers, and a timeout from 1 to 3,600 seconds (default 900). The inspector supplies no directory, so a document that declares threshold approvers fails with the explicit directory requirement. Approval & HITL covers the approval contract.
Resolution limits and merge behavior
resolve_from_path returns one merged HushSpec document, and compilation is a separate operation. Relative extends paths resolve from the referring document's directory; absolute paths are used as written. The filesystem loader reads local files only and does not confine reads to a policy root.
| Setting or result | Behavior | Action |
|---|---|---|
max_ | 4 MiB per document, checked before and during the read | Shrink the file or set a positive limit |
max_ | 32 documents, counting the start; zero is invalid | Flatten the chain or set a positive depth |
Cycle | A repeated canonical source path reports the closing chain | Remove the circular extends |
merge, deep_merge | Replace each supplied rules block whole; differ on extensions | Repeat required settings when overriding |
replace | Discards the parent, keeps the child | Supply every control to retain |
HTTP extends | Treated as a path; the read fails | Use a local file; no network timeout applies |
Set deliberate limits with resolve_from_path_with_limits. A loader you pass to resolve_with_loader controls its own loading behavior and canonical source identifiers; the filesystem behavior above belongs to resolve_from_path. Full merge rules are in HushSpec Policy Format.
pub const DEFAULT_MAX_POLICY_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
pub const DEFAULT_MAX_EXTENDS_DEPTH: usize = 32;Policy hashes and decision records
| Record | Computed over | Kernel signature |
|---|---|---|
compute_ | SHA-256 of the parsed policy serialized as JSON | No |
source_hash | Loaded root-file bytes, plus auxiliary asset digests | No |
runtime_hash | Selected fields of the control-plane runtime fingerprint | No |
DecisionReceipt | One policy interpreter evaluation | No |
Kernel receipt | One mediated decision | Yes |
The compiler first uses compute_policy_hash for a threshold-approval requirement. source_hash covers the root file, never every inherited source file. runtime_hash includes the generated capability configuration and auxiliary assets, never the complete HushSpec. DecisionReceipt is the interpreter's structured record, with no proof of tool dispatch.
The loader rebinds a compiled threshold requirement to runtime_hash before installing it. That fingerprint covers seven listed rule blocks and the reputation settings and omits some other compiled rule and detection fields, so equal runtime hashes alone do not show that every configured control is identical. For signed runtime records, inspect the kernel receipt and its verification result in Receipt Contract.
Policy interpreter receipt and hash implementation
evaluate_audited wraps evaluate with optional timing and hashing. AuditConfig.enabled=false returns zero duration and an empty content hash. redact_content records a flag when content was present; ActionSummary carries no content field. hushspec_version identifies the library version, while policy.version copies the document version.
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())
}The helper falls back to an empty string if serialization fails, so its digest alone does not establish that serialization succeeded.
Compilation failures
| Symptom | Meaning | Next action |
|---|---|---|
Validation failed | The compiler stopped before building controls | Correct the reported version, field, or constraint; warnings alone pass |
Pattern database read or parse failure | Enabled threat_intel needs a readable, valid JSON database | Supply pattern_db and pass the policy source path |
Invalid regex or glob | A guard or scope cannot build its matcher | Correct the named pattern; test intended matches and non-matches |
Threshold approvers require a directory | Approver identities need authentication | Use an approver- |
Empty default_ | No default authority, including for unrepresentable shapes | Inspect tool_access; keep the empty scope over a blanket grant |
Inspector source
The executable calls the Rust library directly. resolve_from_path loads inheritance, and compile_policy_with_source validates and constructs the result. Passing the policy path also gives relative threat-intelligence assets a base directory.
[package]
name = "policy-inspector"
version = "0.1.0"
edition = "2021"
[workspace]
[dependencies]
chio-policy = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
serde_json = "1"use chio_policy::{compile_policy_with_source, resolve_from_path};
use serde_json::{json, Value};
use std::{error::Error, path::Path};
/// Inspect compiler output. This does not issue a capability or invoke a tool.
pub fn inspect(path: &Path) -> Result<Value, Box<dyn Error>> {
let policy = resolve_from_path(path)?;
let compiled = compile_policy_with_source(&policy, Some(path))?;
Ok(json!({
"guard_names": compiled.guard_names,
"output_hooks": compiled.post_invocation.len(),
"default_scope": compiled.default_scope,
"threshold_approval": compiled.threshold_approval.is_some()
}))
}use std::{env, path::Path, process::ExitCode};
fn main() -> ExitCode {
let args: Vec<_> = env::args_os().skip(1).collect();
if args.len() != 1 {
eprintln!("Usage: policy-inspector <policy.yaml>");
return ExitCode::from(2);
}
match policy_inspector::inspect(Path::new(&args[0])) {
Ok(output) => match serde_json::to_string_pretty(&output) {
Ok(text) => {
println!("{text}");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("Could not serialize compiler output: {error}");
ExitCode::FAILURE
}
},
Err(error) => {
eprintln!("Could not compile policy: {error}");
ExitCode::FAILURE
}
}
}The contract tests check inherited guard construction, changed constraints, empty grants, whole-block replacement, resolver and compiler failures, and the permissive result of omitting tool_access. Run them after each policy change.
cargo test --locked --test compilation --quietrunning 8 tests ........ test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
use chio_policy::{
compile_policy, resolve_from_path, resolve_from_path_with_limits, HushSpec, ResolveLimits,
};
use policy_inspector::inspect;
use serde_json::json;
use std::path::Path;
#[test]
fn inherited_policy_builds_both_pipelines_and_one_grant() {
let out = inspect(Path::new("policies/policy.yaml")).unwrap();
assert_eq!(out["guard_names"], json!(["mcp-tool", "secret-leak"]));
assert_eq!(out["output_hooks"], 1);
assert_eq!(out["threshold_approval"], false);
let grants = out["default_scope"]["grants"].as_array().unwrap();
assert_eq!(grants.len(), 1);
assert_eq!(grants[0]["server_id"], "*");
assert_eq!(grants[0]["tool_name"], "count_words");
assert_eq!(grants[0]["constraints"].as_array().unwrap().len(), 1);
}
#[test]
fn stricter_policy_preserves_secret_checks_and_adds_a_constraint() {
let original = inspect(Path::new("policies/policy.yaml")).unwrap();
let strict = inspect(Path::new("policies/attested.yaml")).unwrap();
assert_eq!(strict["guard_names"], original["guard_names"]);
assert_eq!(strict["output_hooks"], 1);
let policy = resolve_from_path("policies/attested.yaml").unwrap();
let tool_access = policy.rules.unwrap().tool_access.unwrap();
assert_eq!(tool_access.max_args_size, Some(2048));
assert_eq!(
strict["default_scope"]["grants"][0]["constraints"]
.as_array()
.unwrap()
.len(),
2
);
assert_ne!(strict["default_scope"], original["default_scope"]);
}
#[test]
fn empty_allowlist_emits_no_default_grants() {
let out = inspect(Path::new("policies/blocked.yaml")).unwrap();
assert_eq!(out["default_scope"], json!({}));
let policy = resolve_from_path("policies/blocked.yaml").unwrap();
assert!(compile_policy(&policy)
.unwrap()
.default_scope
.grants
.is_empty());
assert_eq!(out["guard_names"], json!(["mcp-tool", "secret-leak"]));
assert_eq!(out["output_hooks"], 1);
}
#[test]
fn child_rule_replaces_the_entire_parent_block() {
let parent = HushSpec::parse(include_str!("../policies/base.yaml")).unwrap();
let child =
HushSpec::parse("hushspec: '0.1.0'\nrules:\n tool_access:\n allow: [count_words]\n")
.unwrap();
let merged = chio_policy::merge(&parent, &child);
assert_eq!(
merged.rules.unwrap().tool_access.unwrap().max_args_size,
None
);
}
#[test]
fn depth_limit_counts_the_starting_document() {
let error = resolve_from_path_with_limits(
"policies/policy.yaml",
ResolveLimits {
max_document_bytes: 4096,
max_extends_depth: 1,
},
)
.unwrap_err();
assert!(error.to_string().contains("maximum depth 1"));
}
#[test]
fn document_size_limit_rejects_before_compilation() {
let error = resolve_from_path_with_limits(
"policies/base.yaml",
ResolveLimits {
max_document_bytes: 8,
max_extends_depth: 32,
},
)
.unwrap_err();
assert!(error
.to_string()
.contains("exceeding maximum document size 8"));
}
#[test]
fn invalid_version_is_rejected_by_the_compiler() {
let policy = resolve_from_path("policies/invalid.yaml").unwrap();
assert!(compile_policy(&policy)
.err()
.unwrap()
.to_string()
.contains("unsupported hushspec version"));
}
#[test]
fn missing_tool_access_is_a_wildcard_not_a_denial() {
let policy = HushSpec::parse("hushspec: '0.1.0'\n").unwrap();
let compiled = compile_policy(&policy).unwrap();
assert!(compiled.guard_names.is_empty());
assert_eq!(compiled.default_scope.grants.len(), 1);
assert_eq!(compiled.default_scope.grants[0].tool_name, "*");
}