Chio/Docs

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.


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

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 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:

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

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

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

Each baseline setting applies to every descendant. Keep baselines to invariants such as forbidden paths, secret patterns, and floor rate limits; place other settings in 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. On a deny, the guard that fired is named inside the reason: line, using the compiled guard identifier (forbidden-path, singular — the kebab-case guard name, not the snake_case forbidden_paths rule-block key):

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
tool:       read_file
server:     *
receipt_id: rcpt-019dbbf8-33db-...
policy:     a40c24d0930d773e060fac86dd77e24e68af4cb0a59b1b836759ed63fbaa23b8
source:     d14550004f854d4131839bd2388b3ec9aa3784c898a47f261d434bffbc88d799
mode:       preflight
fixture:    false

# 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
tool:       read_file
server:     *
reason:     guard denied the request: guard "forbidden-path" denied the request: path matches forbidden pattern **/.env
receipt_id: rcpt-019dbbf8-3405-...

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

These four issues 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.

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, 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. Leave merge_strategy unset unless you have a specific reason to change it. Deep merge combines nested extension fields across layers.
  • 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 differ from the baseline after inheritance resolves.

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