Inherit & Merge Policies
Real deployments rarely run a single hand-written policy. A platform team publishes a baseline, a product team tightens it for their domain, and each environment (dev, staging, prod) layers on its own overrides. HushSpec supports this directly with two fields: extends and merge_strategy. This guide covers the composition workflow: how inheritance resolves, how each merge strategy behaves, how to lay out multi-layer policy trees, and how to debug the effective result.
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 a broad tool surface 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 remains the single source of truth.
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 offending policy will fail to load rather than producing a half-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: parent # child left absent, parent carries
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: 300This is the strategy you want for most multi-layer setups. Adding a tier, tweaking a detector threshold, or introducing a new origin profile becomes a small overlay rather than a full restatement.
Layering Patterns
Because extends is a chain, you can build arbitrary layering. Two shapes cover the majority of real deployments.
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: 500
window_seconds: 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: 2000
window_seconds: 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: 120
window_seconds: 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.
To sanity-check the merge, run chio check against the leaf-most file: it will resolve the chain, validate, and emit an allow/deny verdict with the guard that fired:
# Load env/prod.yaml and verify a tool call resolves through the chain
$ chio check --policy ./policies/env/prod.yaml \
--tool read_file \
--params '{"path": "./workspace/README.md"}'
verdict: ALLOW
# Confirm the baseline-inherited forbidden path is still enforced
$ chio check --policy ./policies/env/prod.yaml \
--tool read_file \
--params '{"path": "./workspace/.env"}'
verdict: DENY
guard: forbidden_pathsThe second call proves that env/prod.yaml inherited forbidden_paths from the baseline, even though that rule is never mentioned in the prod or team files.
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.Debugging Merge Conflicts
Most composition bugs fall into one of four shapes. Work through them in order.
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.
Cycle detected error
Symptom: chio check fails with a Cycle error listing a chain like a.yaml -> b.yaml -> a.yaml. The resolver canonicalizes each path, so a cycle can form across symlinks or different relative paths that resolve to the same absolute file. Fix: trace the chain manually and break the loop.
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: it would make the effective surface much harder to reason about.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 is additive and friendliest to multi-layer stacks. - 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 surprise you on the way down the chain.
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