Chio/Docs

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_paths for .env, .ssh, and .pem files, the shared secret-pattern catalog, and the minimum velocity rate 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.

policies/project.yaml
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 formResolved againstExample
RelativeParent directory of the file that contains extends../team/baseline.yaml
AbsoluteFilesystem root/etc/chio/policies/baseline.yaml
Bare filenameSame directory as the childbaseline.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

References that start with 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

If A extends B and B extends A, the resolver returns a 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:

StrategyParent contributionWhen to use
replaceDiscarded entirelyChild is the full policy; parent is structural documentation only
mergeFills any slot the child leaves absent; each rule block is all-or-nothingStable 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

All three strategies treat each entry under 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.

parent.yaml
hushspec: "0.1.0"
name: parent
description: "Parent description"

rules:
  forbidden_paths:
    enabled: true
    patterns:
      - "**/.env"
child.yaml
hushspec: "0.1.0"
name: child
extends: "parent.yaml"
merge_strategy: replace

rules:
  tool_access:
    enabled: true
    default: block
    allow:
      - read_file
effective policy
hushspec: "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.

parent.yaml
hushspec: "0.1.0"
name: parent
description: "Parent description"

rules:
  forbidden_paths:
    enabled: true
    patterns:
      - "**/.env"
  tool_access:
    enabled: true
    default: block
    allow:
      - read_file
child.yaml
hushspec: "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"
effective policy
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. initial and transitions come from the child wholesale.
  • extensions.origins.profiles: unioned by id; 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.
parent.yaml
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: 60
child.yaml
hushspec: "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: 300
effective policy
hushspec: "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: 300

This 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.

policies/baseline.yaml
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: 60
policies/project-search.yaml
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"

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:

policies/team-search.yaml
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/**"
policies/env/dev.yaml
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: 60
policies/env/prod.yaml
hushspec: "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: 60

At 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

A large baseline leaks decisions to every descendant. Keep baselines to true invariants (forbidden paths, secret patterns, floor rate limits) and push everything else down into team or project layers.

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:

bash
# 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_paths

The 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

Write a small script that runs 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

If you find yourself wanting the child's 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. Leave merge_strategy unset unless you have a specific reason to change it. Deep-merge is additive and friendliest to multi-layer stacks.
  • Prefer replace for stable, self-contained environments. If a single policy file fully describes a production deployment and the parent is there only as a template, replace removes the ambiguity.
  • Use merge when 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 in posture.states, origins.profiles, and reputation.tiers are 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 extends resolver is filesystem-only, so the physical layout has to match the logical layout.
  • Test every leaf. Run chio check against 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

The merged HushSpec configures guards. Capability tokens still gate which tools an agent is authorized to invoke. Both must allow a call for it to succeed. See Capabilities for the token side.

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
Inherit & Merge Policies · Chio Docs