BuildPolicy
Inherit & Merge Policies
Layer HushSpec policies with extends and merge_strategy: how inheritance resolves, how each merge strategy folds, and how to debug the effective result.
Prerequisites
- The
chiobinary, and a policy you can already load on its own. Composition multiplies parse errors across a chain, so start from a leaf that works. See Write a Policy. - A directory layout you control. Every
extendsis resolved relative to the file that declares it, so the shape of the tree is part of the policy. - Two SQLite paths for the verification step, plus a small JSON fixture file if any layer in the chain enables
secret_patternsorpatch_integrity.
Why Compose Policies
A single flat policy works for a prototype. It does not scale to an organization with several teams, environments, and tenants. Composition solves three recurring problems:
- Team baselines. Security engineers own the invariants:
forbidden_pathsfor.env,.ssh, and.pemfiles, the shared secret-pattern catalog, and the minimumvelocityrate limit. Product teams should not be able to silently remove those. - Environment overlays. A dev environment allows more tools and a loose egress allowlist. Prod tightens both. The underlying policy is the same, the overlay swaps in the environment-specific fields.
- Multi-tenant tenancy. Each tenant extends a common template with their own
path_allowlist,egress.allow, and origin profiles. The template defines the shared settings.
Composition in HushSpec is a single-parent chain: a child file names one parent via extends, the parent may name its own parent, and so on up to the root. The resolver detects cycles and refuses to load them. There is no multiple inheritance.
The extends Field
extends takes a single string: a relative or absolute filesystem path to another HushSpec document. The resolver reads the parent, applies any extends it declares (recursing to the root), then folds the result into the child using the child's merge_strategy.
hushspec: "0.1.0"
name: project
extends: "../team/baseline.yaml" # relative to this file
merge_strategy: deep_merge # optional; deep_merge is the default
rules:
tool_access:
enabled: true
default: block
allow:
- read_file
- search_files| Path form | Resolved against | Example |
|---|---|---|
| Relative | Parent directory of the file that contains extends | ../team/baseline.yaml |
| Absolute | Filesystem root | /etc/chio/policies/baseline.yaml |
| Bare filename | Same directory as the child | baseline.yaml |
When the CLI loads a HushSpec policy it calls chio_policy::resolve_from_path, which canonicalizes the child path, reads the file, then walks the extends chain. Each parent is merged bottom-up: the furthest ancestor is the starting point, the direct parent is folded in next, and finally the child is applied on top.
HTTP extends is not supported
http:// or https:// are rejected by the resolver. If you need to share baselines across repos, vendor them in or mount them at a known filesystem path.Cycles are detected, not silently broken
Cycle error listing the full chain (for example a.yaml -> b.yaml -> a.yaml). The resolver rejects the policy and does not produce a partially merged result.The Three Merge Strategies
The child sets merge_strategy to one of three values. The field is optional; the default is deep_merge. Each strategy changes how the child folds onto the resolved parent:
| Strategy | Parent contribution | When to use |
|---|---|---|
replace | Discarded entirely | Child is the full policy; parent is structural documentation only |
merge | Fills any slot the child leaves absent; each rule block is all-or-nothing | Stable environments where you want predictable block-level overrides |
deep_merge (default) | Combined field-by-field inside extensions (posture states, origin profiles, reputation tiers, detection knobs) | Additive composition across many layers |
rules blocks are never deep-merged
rules: (for example rules.tool_access, rules.egress, rules.forbidden_paths) as a single slot. If the child defines rules.tool_access at all, the entire child block wins and the parent's tool_access is dropped. To extend a rule list (for example, add allowed tools on top of the parent's list), you must restate the full list in the child. Deep combination applies only inside the extensions: tree.replace
The simplest strategy. The parent file is loaded and validated (so you still get a loader error if it is missing or malformed), then thrown away. The effective policy is exactly the child, minus the extends pointer.
hushspec: "0.1.0"
name: parent
description: "Parent description"
rules:
forbidden_paths:
enabled: true
patterns:
- "**/.env"hushspec: "0.1.0"
name: child
extends: "parent.yaml"
merge_strategy: replace
rules:
tool_access:
enabled: true
default: block
allow:
- read_filehushspec: "0.1.0"
name: child
# description: absent (parent value discarded)
# extends: absent (resolver strips this after merge)
merge_strategy: replace
rules:
tool_access:
enabled: true
default: block
allow:
- read_file
# forbidden_paths is gone; the parent block did not survive.Use replace when the child is authoritative and the parent is there only for editorial convenience (for example to share a schema version header or a comment block).
merge
merge is the mid-level option. Top-level fields (name, description, metadata) and each rule slot are filled in slot-by-slot: the child value wins if present, otherwise the parent value is carried forward. Inside extensions, each sub-extension (posture, origins, detection, reputation, runtime_assurance, chio) is also treated as an all-or-nothing slot.
hushspec: "0.1.0"
name: parent
description: "Parent description"
rules:
forbidden_paths:
enabled: true
patterns:
- "**/.env"
tool_access:
enabled: true
default: block
allow:
- read_filehushspec: "0.1.0"
name: child
extends: "parent.yaml"
merge_strategy: merge
rules:
tool_access:
enabled: true
default: block
allow:
- read_file
- search_files
egress:
enabled: true
default: block
allow:
- "api.github.com"hushspec: "0.1.0"
name: child # child wins
description: "Parent description" # child absent, parent carries forward
merge_strategy: merge
rules:
forbidden_paths: # parent slot, child left absent
enabled: true
patterns:
- "**/.env"
tool_access: # child replaces parent slot wholesale
enabled: true
default: block
allow:
- read_file
- search_files
egress: # new slot from child
enabled: true
default: block
allow:
- "api.github.com"The child's tool_access completely replaces the parent's, even though both are default: block. This is intentional: it makes the override explicit and avoids the surprise of two allow-lists quietly concatenating.
deep_merge (default)
deep_merge behaves like merge for rules, but inside extensions it combines nested structures:
extensions.posture.states: unioned; child states overwrite base states with the same name.initialandtransitionscome from the child wholesale.extensions.origins.profiles: unioned byid; child profiles with the same id replace base ones, new child profiles are appended.extensions.reputation.tiers: unioned; child tier entries overwrite base entries with the same name.extensions.detection: each sub-detector (prompt_injection,jailbreak,threat_intel) is merged field-by-field, so the child can tweak a single threshold without restating the rest.extensions.reputation.scoring.weights: each weight is merged individually; absent weights fall back to the base value.
hushspec: "0.1.0"
name: parent
extensions:
detection:
prompt_injection:
enabled: true
warn_at_or_above: suspicious
max_scan_bytes: 4096
threat_intel:
enabled: true
pattern_db: "base.db"
similarity_threshold: 0.7
reputation:
tiers:
bronze:
score_range: [0.0, 0.5]
max_scope:
operations: ["tool_call"]
ttl_seconds: 60hushspec: "0.1.0"
name: child
extends: "parent.yaml"
# merge_strategy omitted; deep_merge is the default
extensions:
detection:
prompt_injection:
block_at_or_above: critical # add a field
threat_intel:
similarity_threshold: 0.9 # tighten threshold
reputation:
tiers:
silver: # new tier
score_range: [0.5, 0.8]
max_scope:
operations: ["tool_call"]
ttl_seconds: 300hushspec: "0.1.0"
name: child # child sets it, so the child wins
extensions:
detection:
prompt_injection:
enabled: true # from parent
warn_at_or_above: suspicious # from parent
block_at_or_above: critical # from child
max_scan_bytes: 4096 # from parent
threat_intel:
enabled: true # from parent
pattern_db: "base.db" # from parent
similarity_threshold: 0.9 # child override
reputation:
tiers:
bronze: # parent tier preserved
score_range: [0.0, 0.5]
max_scope:
operations: ["tool_call"]
ttl_seconds: 60
silver: # new child tier appended
score_range: [0.5, 0.8]
max_scope:
operations: ["tool_call"]
ttl_seconds: 300Use this strategy for most multi-layer setups. Adding a tier, changing a detector threshold, or introducing an origin profile requires a small overlay instead of a full restatement.
Layering Patterns
Because extends is a chain, you can build a chain of layers. Two common shapes are shown below.
Two-Layer: Baseline + Project
The platform team owns baseline.yaml: forbidden paths, secret patterns, velocity floor. The product team owns project.yaml: which tools their agent uses, which domains it contacts.
hushspec: "0.1.0"
name: org-baseline
description: Platform-wide invariants. Do not remove these in project overlays.
rules:
forbidden_paths:
enabled: true
patterns:
- "**/.env"
- "**/.env.*"
- "**/*.pem"
- "**/*.key"
- "**/.ssh/**"
- "**/.aws/credentials"
secret_patterns:
enabled: true
velocity:
enabled: true
max_invocations_per_window: 500
window_secs: 60hushspec: "0.1.0"
name: project-search
extends: "baseline.yaml"
# deep_merge (default) keeps baseline slots; child adds new ones.
rules:
tool_access:
enabled: true
default: block
allow:
- read_file
- search_files
- fetch
path_allowlist:
enabled: true
read:
- "**/workspace/**"
egress:
enabled: true
default: block
allow:
- "api.github.com"
- "*.openai.com"The project never restates forbidden_paths, secret_patterns, or velocity. The baseline owns them. If the platform team updates the baseline, every project picks up the change on its next load.
Three-Layer: Baseline + Team + Environment
Add a per-environment overlay on top of the team policy for prod and staging distinctions:
hushspec: "0.1.0"
name: team-search
extends: "baseline.yaml"
rules:
tool_access:
enabled: true
default: block
allow:
- read_file
- search_files
- fetch
path_allowlist:
enabled: true
read:
- "**/workspace/**"hushspec: "0.1.0"
name: search-dev
extends: "../team-search.yaml"
rules:
egress:
enabled: true
default: block
allow:
- "api.github.com"
- "*.openai.com"
- "localhost"
- "127.0.0.1"
velocity:
enabled: true
max_invocations_per_window: 2000
window_secs: 60hushspec: "0.1.0"
name: search-prod
extends: "../team-search.yaml"
rules:
egress:
enabled: true
default: block
allow:
- "api.github.com"
- "*.openai.com"
velocity:
enabled: true
max_invocations_per_window: 120
window_secs: 60At runtime, you point the agent at env/dev.yaml or env/prod.yaml. The resolver walks up through team-search.yaml to baseline.yaml and merges in order.
Keep the top of the chain small
Resolving the Effective Policy
When chio check, chio run, or the MCP/ACP edges load a policy file they call chio_policy::resolve_from_path before validation, compilation, or guard-pipeline construction. The resolver walks the extends chain, merges, and returns a single resolved HushSpec with its extends field cleared. That resolved spec is what the runtime actually enforces.
Verify the result
chio policy analyze loads the leaf, resolves the chain and validates the result without evaluating a call. It is the cheapest way to prove a chain still composes:
$ chio policy analyze ./policies/env/prod.yamlpolicy_sha256 e9f82ee3bfb7830a2770fe9521b9de69ac06c54e1e953c5826715890a414428b
ID SEVERITY KIND BLOCK RULE
- notice not_analyzed velocity window
stateful rate-window predicate
- notice not_analyzed velocity burst_factor
stateful rate-window predicate
summary: 0 error(s), 0 warning(s), 2 notice(s)To prove which rules survived the merge, evaluate calls against the leaf. This chain's baseline enables secret_patterns, a post-output guard, so the default preflight mode refuses:
$ chio --session-db ./admission-0.db --receipt-db ./receipts.db \
check --policy ./policies/env/prod.yaml --tool read_file \
--params '{"path": "./workspace/.env"}'error [urn:chio:error:cli:other]: chio check preflight cannot evaluate post-output guards; use --mode full --output-fixture <JSON> so output-sensitive policy is evaluated against explicit fixture output
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.In full mode with a fixture file, a call the leaf's own egress allowlist permits is allowed:
$ chio --session-db ./admission-1.db --receipt-db ./receipts.db \
check --policy ./policies/env/prod.yaml --tool fetch \
--params '{"url": "https://api.github.com/repos/chio/example"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: ALLOW tool: fetch server: * receipt_id: 5ea528a5c39819207760407b98306b269a84bc543aae8db519beea2c78e9601e policy: d55b066432702552c80304cfdd114f5b20d0eece43bc17b8b9d2c8d769b41ef5 source: f67c59707263b8960a6d6bff1751ff18265fa8734f22ac67260d3c2b6166f3ba mode: full fixture: true
A host that allowlist does not cover is denied, which exercises the leaf layer:
$ chio --session-db ./admission-2.db --receipt-db ./receipts.db \
check --policy ./policies/env/prod.yaml --tool fetch \
--params '{"url": "https://paste.example.net/upload"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: DENY tool: fetch server: * reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: fa8683c0ba6130c28c4ab7f2a4c3b7f6f0de02d3eb5416f742826fbd0deea5bb policy: d55b066432702552c80304cfdd114f5b20d0eece43bc17b8b9d2c8d769b41ef5 source: f67c59707263b8960a6d6bff1751ff18265fa8734f22ac67260d3c2b6166f3ba mode: full fixture: true
A path the baseline forbids is denied too, which is the proof that env/prod.yaml inherited forbidden_paths even though neither the prod nor the team file mentions it:
$ chio --session-db ./admission-3.db --receipt-db ./receipts.db \
check --policy ./policies/env/prod.yaml --tool read_file \
--params '{"path": "./workspace/.env"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: DENY tool: read_file server: * reason: guard denied the request: guard "guard-pipeline" denied the request receipt_id: 783536a696db3626b70955c770c29f263079bd6e2a0fe70d8bedbbc9aa52968d policy: d55b066432702552c80304cfdd114f5b20d0eece43bc17b8b9d2c8d769b41ef5 source: f67c59707263b8960a6d6bff1751ff18265fa8734f22ac67260d3c2b6166f3ba mode: full fixture: true
And a tool the middle layer's tool_access does not list is refused before any guard runs:
$ chio --session-db ./admission-4.db --receipt-db ./receipts.db \
check --policy ./policies/env/prod.yaml --tool write_file \
--params '{"path": "./workspace/out.txt", "content": "x"}' \
--mode full --output-fixture ./output-fixture.jsonverdict: DENY tool: write_file server: * reason: requested tool write_file on server * is not in capability scope receipt_id: b23cfdafdfdda1bd8d328322fc755135b9c016fd05179db519c22622a15f8c43 policy: d55b066432702552c80304cfdd114f5b20d0eece43bc17b8b9d2c8d769b41ef5 source: f67c59707263b8960a6d6bff1751ff18265fa8734f22ac67260d3c2b6166f3ba mode: full fixture: true
Reading which layer denied
Compare the three guard denies above and the reason: line is byte for byte the same in each. That line names the top-level registered guard, and the CLI registers one: the pipeline, whose name is the literal guard-pipeline (crates/guards/chio-guards/src/pipeline.rs:58-61, formatted at crates/kernel/chio-kernel/src/kernel/dispatch.rs:400-407). The reason: line therefore cannot tell you which layer's rule fired.
The receipt can. As the pipeline short-circuits it appends a GuardEvidence entry naming the guard that denied, using the compiled kebab-case guard identifier rather than the snake_case rule-block key (crates/guards/chio-guards/src/pipeline.rs:79-87). Read evidence[].guard_name back out of the receipt store:
$ chio --receipt-db ./receipts.db receipt list --admin-all \
| jq -c 'select(.decision.verdict == "deny")
$ | {tool: .tool_name, reason: .decision.reason,
$ evidence: [.evidence[]?.guard_name]}'{"tool":"fetch","reason":"guard denied the request: guard \"guard-pipeline\" denied the request","evidence":["egress-allowlist"]}
{"tool":"read_file","reason":"guard denied the request: guard \"guard-pipeline\" denied the request","evidence":["forbidden-path"]}
{"tool":"write_file","reason":"requested tool write_file on server * is not in capability scope","evidence":[]}
{"tool":"read_file","reason":"guard denied the request: guard \"guard-pipeline\" denied the request","evidence":["path-allowlist"]}Three different guards, one shared reason: string. egress-allowlist comes from env/prod.yaml, path-allowlist from team-search.yaml, and forbidden-path from baseline.yaml. One resolved policy is enforcing all three layers, and the receipt is where that is legible.
path_allowlist cannot be proved in a dry run
path-allowlist deny on a path the team layer allowlists. chio check opens a session that declares no filesystem roots, and PathAllowlistGuard treats an empty root set as matching nothing, so it denies every path-bearing call before it reads its own allowlist (crates/kernel/chio-kernel/src/kernel/dispatch.rs:751-753 with crates/guards/chio-guards/src/path_allowlist.rs:198-203). The evidence still identifies the layer, which is what this section is for, but a live edge is where that rule's allowlist is actually exercised.Smoke-test each layer in CI
chio check against every leaf policy with a known-good and a known-bad tool call. If a platform-level change to the baseline accidentally breaks a project, CI catches it before deploy.Failures and Recovery
These five failures account for most composition bugs.
Slot silently replaced
Symptom: you added an entry under rules.tool_access.allow in the child and the parent's allowed tools disappeared. Cause: rule blocks are all-or-nothing at every strategy. The child's tool_access block replaces the parent's entirely. Fix: copy the parent's list into the child and add your new entries, or move the tool into a higher layer so it is shared.
deep_merge behaves like merge
Symptom: you expected two posture states to coexist but only the child's survived. Cause: either merge_strategy: merge is set (which treats the whole posture block as a slot), or the child and parent used the same state name (in which case deep_merge overwrites). Fix: check merge_strategy, and rename the state if both layers legitimately define distinct states.
Two overlays disagree
HushSpec is a single-parent chain: a child has exactly one extends. If your team has two independent overlays (say a security overlay and a compliance overlay) and wants both, you cannot merge them in parallel. Pick a linear order: make compliance.yaml extend security.yaml, then have projects extend compliance.yaml. Later layers always win against earlier layers for any field they set.
The chain does not resolve
A loop anywhere in the chain is refused with the full path, and the resolver canonicalizes each path first, so a cycle can form across symlinks or across two relative paths that reach the same file:
$ chio policy analyze ./policies/a.yamlpolicy analysis failed: failed to load ./policies/a.yaml: circular extends detected: ~/chio/policies/a.yaml -> ~/chio/policies/b.yaml -> ~/chio/policies/a.yaml
A parent that is not there is refused the same way, naming the resolved path it looked for:
$ chio policy analyze ./policies/c.yamlpolicy analysis failed: failed to load ./policies/c.yaml: failed to read HushSpec document at ~/chio/policies/nope.yaml: No such file or directory (os error 2)
Neither produces a partially merged result. The resolver either returns one resolved HushSpec or an error.
A layer fails to parse
Every layer parses with deny_unknown_fields, so one stale field name in a baseline takes down every descendant. The error names the file, the block and the accepted set:
$ chio policy analyze ./policies/stale.yamlpolicy analysis failed: failed to load ./policies/stale.yaml: failed to parse HushSpec document at ~/chio/policies/stale.yaml: rules.velocity: unknown field `max_invocations`, expected one of `enabled`, `max_invocations_per_window`, `max_spend_per_window`, `max_requests_per_agent`, `max_requests_per_session`, `window_secs`, `burst_factor` at line 19 column 5
Run chio policy analyze over every leaf in CI. A leaf that loads proves its whole chain loads.
There is no partial deep-merge of rule lists
egress.allow entries to be concatenated with the parent's rather than replacing them, restate the full list in the child. HushSpec deliberately does not splice lists across layers, which keeps the resolved policy predictable.Best Practices
- Keep baselines small. A baseline is a contract across many descendants. Every field it sets is a decision every descendant inherits. Put only true invariants at the top.
- Default to
deep_merge. Leavemerge_strategyunset unless you have a specific reason to change it. Deep merge combines nested extension fields across layers. - Prefer
replacefor stable, self-contained environments. If a single policy file fully describes a production deployment and the parent is there only as a template,replaceremoves the ambiguity. - Use
mergewhen you want block-level override semantics. It is the strictest of the merging strategies: every block the child names is a full replacement, every block it omits falls through. This predictability is useful for environment overlays that only touch a couple of rules. - Name states, profiles, and tiers intentionally. Under
deep_merge, same-name entries inposture.states,origins.profiles, andreputation.tiersare overwritten by the child. Distinct names add; matching names override. - Version baselines with the repo. Store baseline and project files in the same repo (or as a vendored submodule). The
extendsresolver is filesystem-only, so the physical layout has to match the logical layout. - Test every leaf. Run
chio checkagainst every file you actually deploy, not just the baseline. The effective policy can differ from the baseline after inheritance resolves.
Inheritance composes, capabilities gate
Next Steps
- Write a Policy · the full guard-by-guard field reference that each layer in your chain can configure
- Custom Guards · once your baselines cover the built-in guards, add bespoke guards for domain-specific rules
- Policy Schema Reference · canonical schema for
extends,merge_strategy, and every rule and extension block - Guards · how the compiled effective policy maps onto the guard pipeline at runtime