Skip to content

Loading and composing policies

Section 5 · From docs/MCP_PRIVACY.md on GitHub

5.1 Ways to load a policy

CloudGMCPLayer(policy=...) accepts anything Policy.load() accepts:

Value Result
None $CLOUDG_MCP_POLICY if set and non-empty (any of the forms below), otherwise the standard profile.
A Policy Used as is.
A PolicyConfig Compiled directly. If it sets extends, the parent is resolved as for a dict, and only the fields set explicitly on the object override it.
A dict Validated, with extends resolved; relative extends paths are resolved against the current directory.
An os.PathLike Policy.from_file().
A string starting with { Parsed as inline JSON.
A string naming a profile Policy.from_profile(). Hyphens and underscores are interchangeable: read-only finds read_only.yaml.
Any other string A path. It must exist, or end in .yaml, .yml or .json (then a missing file raises FileNotFoundError).

Files ending in .json are parsed as JSON, everything else as YAML. A document whose only top-level key is policy is unwrapped, so {"policy": {...}} works. Unknown keys anywhere raise ValueError("Invalid policy: ...") (every model uses extra="forbid"). The policy-level transforms and input_transforms are built at load time, so an unknown type or a bad option there fails immediately. Transforms declared inside roles and rules, and transform_options, are only built when a pipeline is first assembled, so a mistake there surfaces on the first call that uses it; run preview_transform with a tool and the right role after changing them.

On the command line, cloudg mcp serve --policy NAME|PATH|JSON does the same (--policy also reads CLOUDG_MCP_POLICY).

from cloudg.mcp.policy import Policy

Policy.load("strict")
Policy.load("/etc/cloudg/policy.yaml")
Policy.load('{"extends": "read_only", "name": "from-env", "audit": true}')
Policy.load({"name": "mine", "extends": "standard", "deny_tools": ["export_*"]})

describe() reports where a policy came from under loaded_from: profile:strict, a file path, dict, json, config or derived:<name>.

5.2 extends and merge rules

extends names a profile or a file. A relative path is resolved against the directory of the file that contains the extends. Chains can be as long as you like; a cycle raises ValueError("Policy 'extends' cycle: ..."). If the child sets no name, it is called custom(<parent name>). The resolved chain is kept as policy.extends_chain (for example ["standard", "read_only"] for audit).

merge_policy_dicts(parent, child) merges key by key:

Key Rule
Scalars (name, audit, max_sensitivity, hide_denied...) Child replaces parent.
Mappings (roles, transform_options, vault, a role's body...) Merged recursively with the same rules.
Any key starting with deny_ (at any depth) Union, parent order first, duplicates removed.
rules, detectors, rate_limits, disabled_detectors (at any depth, so also inside a role) Concatenated, parent first.
transforms, input_transforms Replaced.
allow_* lists Replaced.
extends Never copied.

Two consequences matter in practice. A child cannot remove a deny entry it inherited, because deny lists only grow; it can remove a denied capability with a policy-level allow_capabilities, which is subtracted from the effective deny set after the merge. A child that wants to change one option of an inherited transform should use transform_options instead of restating the list.

Merging strict with this child, measured:

Policy.load({
    "name": "team", "extends": "strict",
    "deny_tools": ["export_*"],
    "roles": {"admin": {"rate_limits": [{"tools": ["*"], "rate": 5}]}},
    "transforms": ["sanitize", {"type": "redact", "options": {"strategies": {"secret": "redact"}}}],
    "transform_options": {"sanitize": {"max_free_text": 300}},
    "allow_capabilities": ["write_fs"],
})
Field Result
extends_chain ["standard", "strict"]
deny_capabilities (as written) reveal, cloud_access, exec, write_fs
Effective denied capabilities cloud_access, exec, reveal
deny_tools ["export_*"]
rules never-reveal (inherited)
rate_limits reveal_token 30/minute, every tool 240/minute (both inherited)
roles.admin allow_capabilities: [reveal] from standard, plus the new 5/minute limit
transforms Exactly the two listed in the child
transform_options {"sanitize": {"max_free_text": 300}}

5.3 derive()

policy.derive(**overrides) builds a new Policy from the current configuration with overrides merged on top by the rules above. The new policy shares the same TokenVault object, so pseudonyms issued by one are understood by the other. The name stays unless you override it, loaded_from becomes derived:<name>, and the extends chain gains the parent's name (Policy.load("strict").derive(audit=True).extends_chain is ["standard", "strict"]).

base = Policy.load("standard")
narrow = base.derive(deny_tools=["map_inventory"], max_sensitivity="internal")
assert narrow.vault is base.vault