Skip to content

Recipes

Section 17 · From docs/MCP_PRIVACY.md on GitHub

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

transform_options:
  redact:
    strategies: {tag_value: drop, person: drop}

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_only hides the tools that call cloud APIs, run scanners or write files, and keeps standard's redaction, sanitising and secret guard. --read-only also drops those tools (and destructive ones) from the registry, so no role can bring them back.
  • --auth-token makes every HTTP request present a bearer token and maps it to a principal; env: keeps tokens out of the process list. The admin role may call reveal_token, which the standard family grants it.
  • --allowed-host turns the Host header 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-log records 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.