Inherit & Merge Policies
Layer HushSpec files from a team baseline to an environment overlay, and read the resolved policy before any agent runs under it.
On this page
A deployed policy can come from three files: a team baseline, a project layer, and an environment overlay. The resolver folds the chain into one HushSpec document, and the kernel enforces that resolved document.
A child names one parent with extends. A rule block the child declares replaces the parent's block whole, under every merge strategy, so a child that overrides a block restates every restriction it keeps. A parent is a shared starting point that any child can override, so mandatory organization policy belongs in reviewed deployment inputs or a separate enforcement boundary.
1. Inspect a complete override
Download the policy examples and unzip them; they are the files the HushSpec reference uses. strict.yaml extends policy.yaml, which extends base.yaml.
hushspec: "0.1.0"
extends: policy.yaml
rules:
tool_access:
enabled: true
default: block
allow: [quote]
max_args_size: 2048
require_runtime_assurance_tier: attestedThe parent chain: base.yaml and policy.yaml
hushspec: "0.1.0"
name: pricing-base
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: larkfield-api-key
pattern: 'lark_live_[A-Za-z0-9]{24}'
severity: errorunzip hushspec-policies.zip
chio policy explain hushspec-policies/policy.yamlpolicy hushspec-policies/policy.yaml extends hushspec-policies/base.yaml
grants quote on server * max_args_size 4096
guards 1 mcp-tool
2 secret-leak
output 1 hook
approvals nonechio policy explain hushspec-policies/strict.yamlpolicy hushspec-policies/strict.yaml extends hushspec-policies/policy.yaml
grants quote on server * max_args_size 2048, runtime assurance attested
guards 1 mcp-tool
2 secret-leak
output 1 hook
approvals noneYou should see the parent grant quote with a 4,096-byte argument limit, then the strict child grant the same tool with a 2,048-byte limit and an attested runtime requirement. The extends line names each chain. The strict child keeps the named tool, lowers the limit, and adds the runtime constraint by repeating the whole tool_access block; policy.yaml contributes the secret check, compiled as secret-leak. chio policy explain prints the compiled controls without issuing authority or running a tool.
2. See an incomplete override widen the grant
incomplete.yaml declares tool_access with one field, max_args_size. Its block still replaces the parent's whole block.
An omitted block is inherited whole
policy.yaml declares only secret_patterns, so tool_access resolves to the base block: default block, allow [quote], max_args_size: 4096. The default grant names quote with a 4,096-byte argument limit. Author a policy.
tool_access block of base.yaml resolved under policy.yaml, strict.yaml, and incomplete.yaml, with the first default grant from each captured compile. Other rule blocks, extensions, and the runtime-assurance constraint are left out.HushSpec policy formathushspec: "0.1.0"
name: incomplete-override
extends: policy.yaml
rules:
tool_access:
max_args_size: 2048chio policy explain hushspec-policies/incomplete.yamlpolicy hushspec-policies/incomplete.yaml extends hushspec-policies/policy.yaml
grants * on server * max_args_size 2048
guards 1 mcp-tool
2 secret-leak
output 1 hook
approvals noneYou should see a grant for *: an agent holding it may call refund and discount as freely as quote. The policy compiles, and the omitted default and allow took their own field defaults along with the replaced block. Compiler success alone says nothing about whether a child narrows its parent, so read the resolved grant for every override.
3. Name the parent with extends
extends takes one string: a filesystem path to another HushSpec document. Composition is a single-parent chain, and each parent may name its own parent up to the root.
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 | The directory of the file that declares extends | ../team/baseline.yaml |
| Absolute | The filesystem root | /etc/chio/policies/baseline.yaml |
| Bare filename | The child's own directory | baseline.yaml |
The CLI loads a HushSpec policy through chio_policy::resolve_from_path, which canonicalizes the child path, reads the file, and walks the chain. Merging runs bottom-up: the furthest ancestor first, then each parent in turn, and the child last. The resolver rejects references that start with http:// or https://, so vendor shared baselines into the repository or mount them at a known path, and keep the physical layout equal to the logical one.
4. Choose how the child folds onto its parent
The child sets merge_strategy. The field is optional, and the default is deep_merge.
replace. The resolver loads and validates the parent, then discards it. The effective policy is the child without itsextendspointer. Use it when one file fully describes a deployment and the parent is a template.merge. The child's value wins for each top-level field (name,description,metadata) and each rule slot it sets; absent slots carry the parent forward. Each extension sub-block (posture,origins,detection,reputation,runtime_assurance,chio) is one slot. Use it for overlays that replace a couple of blocks.deep_merge, the default. Rules behave as undermerge. Insideextensions, posture states, origin profiles, and reputation tiers combine by name orid; detection settings and scoring weights merge one field at a time. Use it for most multi-layer chains.
Every strategy treats each entry under rules: as one slot. To add a tool to the parent's allow list, restate the full list in the child: HushSpec never splices lists across layers, which keeps the resolved policy predictable. Deep combination applies only inside extensions:, where a same-name state, profile, or tier in the child overwrites the parent's and a new name adds one.
Parent, child, and effective policy for each strategy are in Merge strategies, worked.
5. Build a baseline, team, and environment chain
The platform team owns the baseline: forbidden paths, secret patterns, and a velocity floor. Keep it to invariants, because every field it sets reaches every descendant.
hushspec: "0.1.0"
name: larkfield-baseline
description: Shared restrictions. Repeat them when overriding a rule block.
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: 60The pricing team's layer names its tools and its workspace. Each environment overlay extends the team layer and sets egress and velocity. In a multi-tenant deployment, each tenant extends a common template the same way, with its own path_allowlist, egress.allow, and origin profiles.
hushspec: "0.1.0"
name: team-pricing
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-prod
extends: "../team-pricing.yaml"
rules:
egress:
enabled: true
default: block
allow:
- "api.github.com"
- "*.openai.com"
velocity:
enabled: true
max_invocations_per_window: 120
window_secs: 60Point the agent at env/prod.yaml or env/dev.yaml, and the resolver walks up through team-pricing.yaml to baseline.yaml. Prod never restates forbidden_paths or secret_patterns, so a baseline change reaches it on its next load. It does restate velocity, and its 120 invocations per 60 seconds replace the baseline's 500; the dev overlay in the reference raises the same block to 2,000.
6. Analyze the resolved chain
chio check, chio run, and the MCP and ACP edges call resolve_from_path before validation, compilation, or guard-pipeline construction, and get back one HushSpec with extends cleared. chio policy analyze loads the leaf, resolves its chain, and validates the result without evaluating a call.
$ 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)You should see no errors or warnings and two notices: the velocity window and burst factor are stateful predicates the analyzer leaves to runtime. A leaf that loads proves its whole chain loads, so run chio policy analyze over every deployed leaf in CI, with one known-good and one known-bad chio check call each. The baseline enables secret_patterns, a post-output guard, so the default preflight mode refuses to evaluate this leaf:
$ chio --session-db ./admission-0.db --receipt-db ./receipts.db \
check --policy ./policies/env/prod.yaml --tool read_file \
--params '{"path": "./workspace/.env"}'error[CHIO-CLI-PREFLIGHT-NEEDS-OUTPUT]: preflight cannot evaluate post-output guards, because it never runs the tool help: run the check with --mode full --output-fixture <JSON> so output-sensitive guards see explicit fixture output code: urn:chio:error:cli:preflight-needs-output docs: https://chio.computer/docs/reference/errors#error-cli-preflight-needs-output
7. Read which layer refused each call
Check calls against the prod leaf in full mode with an output fixture file, which any chain that enables secret_patterns or patch_integrity needs. Each check takes its own admission database and the shared receipt database. A host on the prod egress list 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 path the baseline forbids is refused, although neither the prod file nor the team file mentions forbidden_paths:
$ 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
The egress refusal and the out-of-scope tool
$ 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
$ 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
Every guard refusal shows the same reason: line, because it names the one guard the CLI registers, guard-pipeline. As the pipeline short-circuits, it records the guard that refused in the receipt's evidence:
$ 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"]}You should see egress-allowlist from env/prod.yaml, forbidden-path from baseline.yaml, and path-allowlist from team-pricing.yaml: one resolved policy enforcing all three layers, each named by its compiled kebab-case guard id. write_file carries no evidence, because capability selection refused it before any guard ran. A call succeeds only when its capability grant and the resolved policy both allow it.
The path-allowlist row refuses an allowlisted path. chio check opens a session that declares no filesystem roots, and PathAllowlistGuard matches nothing against an empty root set, so it refuses every path-bearing call before reading its allowlist. A live edge, where the client supplies roots, exercises that allowlist.
A broken chain is refused whole, with the file named
The resolver returns one resolved HushSpec or an error, never a partial merge.
- The parent's allowed tools disappeared. The child declared
rules.tool_access, which replaces the parent's block at every strategy. Copy the parent's list into the child and add to it, or move the tool into a higher layer. - Two posture states were expected and one survived. Either
merge_strategy: mergetreats the wholepostureblock as one slot, or both layers used the same state name anddeep_mergeoverwrote it. Check the strategy, and rename states that are distinct. - Two overlays both need to apply. A child has exactly one
extends. Put them in a line:compliance.yamlextendssecurity.yaml, and projects extendcompliance.yaml. A later layer wins for every field it sets. - The chain loops or a parent is missing. The resolver canonicalizes each path first, so a cycle can form through symlinks or two relative paths to one file; it returns a
Cycleerror listing the full chain. A missing parent is reported with the resolved path it looked for. - A layer fails to parse. Every layer parses with
deny_unknown_fields, so one stale field in a baseline stops every descendant. The error names the file, the block, and the accepted fields:
$ 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
Captured cycle and missing-parent errors
$ 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
$ 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)
Merge strategies, worked
Try replace, deep_merge, reputation, or dev.
replace
The parent loads and validates, so a missing or malformed parent still fails the load, and then it is discarded. The effective policy is the child minus extends.
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.merge
Slot by slot, the child's value wins where present and the parent's carries forward where absent. The child's tool_access replaces the parent's even though both use default: block, which keeps the override explicit and stops two allow lists from concatenating.
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"deep_merge
extensions.posture.states: unioned; a child state overwrites a parent state with the same name.initialandtransitionscome from the child whole.extensions.origins.profiles: unioned byid; a child profile with the same id replaces the parent's, and new child profiles are appended.extensions.reputation.tiers: unioned; a child tier overwrites a parent tier with the same name.extensions.detection: each detector (prompt_injection,jailbreak,threat_intel) merges field by field, so a child can change one threshold.extensions.reputation.scoring.weights: each weight merges individually; absent weights keep the parent's 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: 300Two layers: baseline and project
A product team's project extends the baseline directly and adds the tools its agent uses and the domains it contacts. The baseline's forbidden_paths, secret_patterns, and velocity carry forward untouched.
hushspec: "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"Three layers: the dev overlay
The dev overlay extends the same team layer as prod, allows loopback egress, and raises the velocity block to 2,000 invocations per 60 seconds.
hushspec: "0.1.0"
name: search-dev
extends: "../team-pricing.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: 60Where the pipeline names the guard
The pipeline's registered name, guard-pipeline, is set in crates/guards/chio-guards/src/pipeline.rs:58-61 and formatted into the reason at crates/kernel/chio-kernel/src/kernel/dispatch.rs:400-407. The evidence entry naming the refusing guard is appended at crates/guards/chio-guards/src/pipeline.rs:79-87. The empty-root refusal comes from crates/kernel/chio-kernel/src/kernel/dispatch.rs:751-753 with crates/guards/chio-guards/src/path_allowlist.rs:198-203.