Recipes¶
All of these were run against the sample estate.
17.1 Share findings with an external LLM without leaking account IDs¶
Keep everything readable except the identifiers that place resources in your organisation: accounts, subscriptions, projects, and the full resource IDs that contain them.
name: external-llm
extends: standard
transform_options:
redact:
strategies:
aws_account_id: pseudonymize
azure_subscription_id: pseudonymize
gcp_project_id: pseudonymize
gcp_project_number: pseudonymize
aws_arn: pseudonymize
azure_resource_id: pseudonymize
gcp_resource_name: pseudonymize
list_findings {"min_severity": "HIGH", "limit": 3} then returns the SSH finding with resource_arn: arn:aws:ec2:us-east-1:544226932392:security-group/res-64694da812, while titles, severities and asset names such as sg-admin stay readable. Passing that pseudonymised ARN to list_findings {"resource": ...} finds the finding again. Because transform_options merges into the inherited redactor, secrets are still redacted and credentials masked. If you pseudonymise the whole identifier category instead, cloudg's asset ids and names are pseudonymised too, and the result reads like strict.
If the model never needs to pass identifiers back, hash is simpler than pseudonymize: it keeps values joinable across results, cannot be reversed, and needs no vault file.
17.2 SOC analysts see everything, contractors see pseudonyms¶
Two roles on one server, with HTTP tokens.
name: soc-contractors
extends: standard
deny_capabilities: [cloud_access, exec]
roles:
soc:
max_sensitivity: restricted
allow_capabilities: [reveal]
contractor:
max_sensitivity: confidential
deny_tools: ["export_*", "get_asset_metadata"]
transform_options:
redact:
strategies:
identifier: pseudonymize
uuid: keep
network: pseudonymize
special_ip: keep
special_cidr: keep
pii: pseudonymize
free_text: pseudonymize
vault:
scope: global # contractors and SOC share pseudonyms, so they can talk about the same resource
cloudg mcp serve --transport http --host 0.0.0.0 --policy soc-contractors.yaml \
--auth-token env:SOC_TOKEN:soc --auth-token env:CONTRACTOR_TOKEN:contractor
Measured with principals c1 (contractor) and s1 (soc) asking for web-1:
| Field | contractor | soc |
|---|---|---|
name |
res-3c1f1e6b50 |
web-1 |
arn |
arn:aws:ec2:us-east-1:544226932392:instance/res-f7ce1c6611 |
arn:aws:ec2:us-east-1:111111111111:instance/i-0web1 |
tags.Owner |
person-6bbd789f86 |
alice@example.com |
| Visible tools | 62, no reveal_token |
70, with reveal_token |
The contractor can pass the pseudonymised ARN back to get_asset and gets the same asset. The SOC member can reveal a sentence a contractor quotes: reveal_token {"token": "look at arn:aws:ec2:us-east-1:544226932392:instance/res-f7ce1c6611"} returned look at arn:aws:ec2:us-east-1:111111111111:instance/i-0web1 with replaced: 2 (the account and the instance). An account alias rule can be added for both roles; it labels the contractor's pseudonymised account as well (section 10.2). If contractors should not be able to correlate each other's results, use scope: principal and give each contractor their own token and id.
17.3 Alias table for account names¶
Show prod instead of 111111111111, for everyone, and accept the alias in arguments.
# accounts.yaml
aliases:
aws_account_id:
"111111111111": prod
"222222222222": shared-services
"999999999999": vendor-co
name: aliased
extends: standard
rules:
- name: account-aliases
transforms:
- type: alias
options:
files: [/etc/cloudg/accounts.yaml]
find_assets {"query": "deploy", "limit": 1} returns "account_id": "shared-services" and "arn": "arn:aws:iam::shared-services:role/deploy-role", with "aliased": 2 in the report, and find_assets {"account_id": "shared-services"} finds the account's 4 assets because the alias is reversed on input. The alias runs after the redactor wherever the rule puts it (section 10.2), so detection always sees the real account. Aliased ARNs are no longer well-formed (arn:aws:iam::shared-services:...), which is fine for reading; tools accept them because the alias is reversed before the handler runs.
17.4 Drop tags entirely¶
Two ways, with different results:
transform_options:
project:
exclude: ["**.raw_data", "**._raw", "**.tags"] # restate the inherited excludes
removes every tags object (get_asset for web-1 has no tags key at all), while
removes tag values except those under allow-listed keys: web-1's tags become {"env": "prod"}. The second keeps the operational tags the model can reason with. Note that the projection override replaces the inherited exclude list (options merge, but lists inside them are replaced), so restate **.raw_data and **._raw.
17.5 Custom detector for internal ticket IDs¶
name: tickets
extends: standard
detectors:
- name: jira_ticket
entity: ticket_id
category: internal
pattern: '\b(?:SECOPS|INFRA)-\d{2,6}\b'
confidence: 0.9
hints: ["secops-", "infra-"]
transform_options:
redact:
strategies:
ticket_id: hash
With a tag Ticket: "Opened in SECOPS-4821 by on-call" on web-1, get_asset returns "Ticket": "Opened in ticket_id:b3bf9c7e5ed7 by on-call" and the report shows "hashed": {"ticket_id": 1}. list_detectors {"category": "internal"} lists the detector with strategy hash and active: true. Hashing keeps the same ticket recognisable across results without revealing its number.
17.6 Read-only production deployment over HTTP with tokens¶
export CLOUDG_MCP_VAULT_KEY="$(cat /run/secrets/cloudg-vault-key)"
export CLOUDG_MCP_AUDIT_SALT="$(cat /run/secrets/cloudg-audit-salt)"
export ANALYST_TOKEN="$(cat /run/secrets/analyst-token)"
export ADMIN_TOKEN="$(cat /run/secrets/admin-token)"
cloudg mcp serve --transport http --host 0.0.0.0 --port 8765 \
--policy read_only --read-only \
--dataset prod=/srv/cloudg/prod/inventory \
--auth-token env:ANALYST_TOKEN:analyst:analyst-1 \
--auth-token env:ADMIN_TOKEN:admin:ops-admin \
--allowed-host 'cloudg.internal.example.com:*' \
--audit-log /var/log/cloudg/mcp-audit.jsonl \
--max-concurrency 8
What each part does:
--policy read_onlyhides the tools that call cloud APIs, run scanners or write files, and keeps standard's redaction, sanitising and secret guard.--read-onlyalso drops those tools (and destructive ones) from the registry, so no role can bring them back.--auth-tokenmakes every HTTP request present a bearer token and maps it to a principal;env:keeps tokens out of the process list. Theadminrole may callreveal_token, which the standard family grants it.--allowed-hostturns theHostheader check back on. On a loopback bind the native server only accepts loopback host names; on any other bind the check is off unless you list the names clients will use. The patterns are fnmatch globs matched against the whole header, port included, hence the:*.--audit-logrecords every call with hashed arguments. The salt and vault key come from the environment so that hashes and pseudonyms stay stable across restarts.
The same command, run on the sample estate with --host 0.0.0.0, an extra --allowed-host '127.0.0.1:*' for the test client, and the native flavor, behaved like this:
| Request | Answer |
|---|---|
No Authorization header |
HTTP 401, {"code": -32600, "message": "Unauthorized"} |
Valid token, Host: evil.example:18766 |
HTTP 421, Invalid Host header |
Analyst tools/list |
65 tools: no reveal_token, no map_inventory, no export_report |
Admin tools/list |
66 tools, reveal_token included |
Analyst get_asset_metadata {"ref": "web-1"} |
Allowed (read_only sets no sensitivity ceiling), user_data redacted |
and the audit file, created with mode 0600, got lines like:
{"argument_hashes":{"ref":"f84e1d23d9469af9"},"argument_keys":["ref"],"duration_ms":3.27,"event":"mcp.call","kind":"tool","name":"get_asset_metadata","outcome":"ok","principal":{"id":"analyst-1","roles":["analyst","default"]},"request_id":3,"sensitivity":"restricted","transforms":{"redacted":{"sensitive_field":1},"content_annotations":{"audience":["user"],"priority":0.75},"annotations":{"...":"..."}},"ts":"2026-10-08T15:17:06.033+00:00"}
If analysts should not read raw metadata at all, add a role with max_sensitivity: confidential in a policy that extends read_only. Put TLS in front (a reverse proxy) when clients are not on the same host. Without --auth-token the native server still starts on a non-loopback address but logs a warning that anyone who can reach it can call the tools.