Chio/Docs
LOGIN · JOIN

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.

Guide chio@5f63e6e

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.

Start

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-policies/strict.yaml · completeyaml
hushspec: "0.1.0"
extends: policy.yaml
rules:
  tool_access:
    enabled: true
    default: block
    allow: [quote]
    max_args_size: 2048
    require_runtime_assurance_tier: attested
The parent chain: base.yaml and policy.yaml
hushspec-policies/base.yaml · completeyaml
hushspec: "0.1.0"
name: pricing-base
rules:
  tool_access:
    enabled: true
    default: block
    allow: [quote]
    max_args_size: 4096
hushspec-policies/policy.yaml · completeyaml
hushspec: "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: error
Terminal · beside hushspec-policiesbash
unzip hushspec-policies.zip
chio policy explain hushspec-policies/policy.yaml
Output
policy     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  none
Terminal · beside hushspec-policiesbash
chio policy explain hushspec-policies/strict.yaml
Output
policy     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  none

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

Resolving one rule blockChoose what the child declares.
π₀base.yamlπ₁policy.yaml∅field defaultsdefaultblockallow[quote]max_args_size4096κtool_namequotemax_args_size4096π₀base.yamlπ₁child∅field defaultsdefaultblockallow[quote]max_args_size4096κtool_namequotemax_args_size4096

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.

The 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 format
hushspec-policies/incomplete.yaml · intentionally permissiveyaml
hushspec: "0.1.0"
name: incomplete-override
extends: policy.yaml
rules:
  tool_access:
    max_args_size: 2048
Terminal · beside hushspec-policiesbash
chio policy explain hushspec-policies/incomplete.yaml
Output
policy     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  none

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

policies/project.yamlyaml
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
RelativeThe directory of the file that declares extends../team/baseline.yaml
AbsoluteThe filesystem root/etc/chio/policies/baseline.yaml
Bare filenameThe child's own directorybaseline.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.

  1. replace. The resolver loads and validates the parent, then discards it. The effective policy is the child without its extends pointer. Use it when one file fully describes a deployment and the parent is a template.
  2. 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.
  3. deep_merge, the default. Rules behave as under merge. Inside extensions, posture states, origin profiles, and reputation tiers combine by name or id; 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.

policies/baseline.yamlyaml
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: 60

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

policies/team-pricing.yamlyaml
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/**"
policies/env/prod.yamlyaml
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: 60

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

policy-layers · analyzetranscript
$ chio policy analyze ./policies/env/prod.yaml
policy_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)
exit 0

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:

policy-layers · preflighttranscript
$ 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
exit 1

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:

policy-layers · allowtranscript
$ 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.json
verdict:    ALLOW
tool:       fetch
server:     *
receipt_id: 5ea528a5c39819207760407b98306b269a84bc543aae8db519beea2c78e9601e
policy:     d55b066432702552c80304cfdd114f5b20d0eece43bc17b8b9d2c8d769b41ef5
source:     f67c59707263b8960a6d6bff1751ff18265fa8734f22ac67260d3c2b6166f3ba
mode:       full
fixture:    true
exit 0allow

A path the baseline forbids is refused, although neither the prod file nor the team file mentions forbidden_paths:

policy-layers · deny-forbiddentranscript
$ 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.json
verdict:    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
exit 2deny
The egress refusal and the out-of-scope tool
policy-layers · deny-egresstranscript
$ 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.json
verdict:    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
exit 2deny
policy-layers · deny-scopetranscript
$ 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.json
verdict:    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
exit 2deny

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:

policy-layers · evidencetranscript
$ 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"]}
exit 0

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: merge treats the whole posture block as one slot, or both layers used the same state name and deep_merge overwrote 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.yaml extends security.yaml, and projects extend compliance.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 Cycle error 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:
policy-layers · stale-velocitytranscript
$ chio policy analyze ./policies/stale.yaml
policy 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
exit 2
Captured cycle and missing-parent errors
policy-layers · cycletranscript
$ chio policy analyze ./policies/a.yaml
policy analysis failed: failed to load ./policies/a.yaml: circular extends detected: ~/chio/policies/a.yaml -> ~/chio/policies/b.yaml -> ~/chio/policies/a.yaml
exit 2
policy-layers · missing-parenttranscript
$ chio policy analyze ./policies/c.yaml
policy 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)
exit 2

Merge strategies, worked

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.

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

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

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

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.

parent.yamlyaml
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.yamlyaml
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 policyyaml
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. initial and transitions come from the child whole.
  • extensions.origins.profiles: unioned by id; 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.
parent.yamlyaml
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.yamlyaml
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 policyyaml
hushspec: "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: 300

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

policies/project-search.yamlyaml
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.

policies/env/dev.yamlyaml
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: 60

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

Next: configure each layer's guards