Loading and composing policies¶
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"]).