Skip to content

Auditing

Section 15 · From docs/MCP_PRIVACY.md on GitHub

There are two independent trails: the policy's own decision log, and the JSONL middleware.

15.1 The policy decision log

Five decisions are recorded:

Decision When Recorded
allowed check_call let the call through only with audit: true, or for a primitive with the reveal capability
denied the access decision refused the call always
rate_limited a bucket was empty always
rejected the call was allowed but the input pipeline refused it (secrets in the arguments); the allowed entry for the same call is changed in place, or a new entry is added always
not_found the caller asked for a primitive that does not exist or that the policy hides always

Entries go into policy.audit_log, a deque of at most audit_size entries (none with audit_size: 0). Three entries from a strict-based policy with audit: true and hide_denied: false (an allowed get_asset, a denied map_inventory, a find_assets refused for a password in its query):

{"ts": 1791472451.6623454, "decision": "allowed", "kind": "tool", "name": "get_asset",
 "principal": "local", "roles": ["default", "local"],
 "arguments": {"ref": "b539e7b9fccf", "max_relations": "6a42492345c1"}}
{"ts": 1791472451.6685236, "decision": "denied", "kind": "tool", "name": "map_inventory",
 "principal": "local", "roles": ["default", "local"], "arguments": {},
 "reason": "capability 'cloud_access' is denied by policy 'strict-audit'"}
{"ts": 1791472451.6690457, "decision": "rejected", "kind": "tool", "name": "find_assets",
 "principal": "local", "roles": ["default", "local"], "arguments": {"query": "9c3249800bad"},
 "reason": "Arguments appear to contain secrets (password); refusing to process them. Pass resource references, never credentials."}

A call to a hidden tool, here reveal_token under strict:

{"ts": 1791472488.0581996, "decision": "not_found", "kind": "tool", "name": "reveal_token",
 "principal": "local", "roles": ["default", "local"], "arguments": {},
 "reason": "unknown or hidden by policy"}

kind is tool, resource, resource_template, prompt or completion; name is the tool or prompt name, or the resource URI or URI template. arguments maps each argument name to a 12-hex keyed hash of its JSON value (keyed by the vault's hash key under the entity audit), so an auditor with the same key can check whether a given value was used without the log holding it.

The same events are logged on the cloudg.mcp.audit logger at INFO when audit is on or the decision is not allowed:

denied tool map_inventory principal=local capability 'cloud_access' is denied by policy 'strict-visible'

Every allowed call to a primitive with the reveal capability also logs a WARNING whatever the settings, and reveal_token logs a second WARNING with the entity type (or the number of embedded pseudonyms, or reversed=N when aliases were involved) and a hash of the pseudonym, never the revealed value:

REVEAL principal=root roles=['admin'] tool=reveal_token args={'token': '9bcfc91eeb45'} policy=standard
reveal_token principal=root embedded_tokens=0 token_hash=044557df7d9f

Like denials, rejected and not_found decisions are logged at INFO whatever the audit setting (not_found tool reveal_token principal=local unknown or hidden by policy), and they bump the rejected and hidden counters.

Route the logger wherever your logs go:

import logging
logging.getLogger("cloudg.mcp.audit").addHandler(logging.FileHandler("/var/log/cloudg/audit.log"))

15.2 AuditLogMiddleware

AuditLogMiddleware writes one JSON line per tool call, resource read and prompt, including cache hits and rejected calls. cloudg mcp serve --audit-log FILE (or CLOUDG_MCP_AUDIT_LOG) puts it outermost in the middleware stack.

Argument Default Meaning
path null JSONL file, opened for append and created with mode 0600.
stream null An open text stream instead.
salt $CLOUDG_MCP_AUDIT_SALT, else random per process HMAC key for argument hashes.
hash_values true false logs argument names only.

With neither path nor stream, lines go to the cloudg.mcp.audit logger. A failure to write is logged and never breaks the call.

Three lines from a strict-based policy with the middleware (salt docs-salt): a successful call, a denial, and a call refused for a secret in its arguments.

{"argument_hashes":{"max_relations":"e985b4f7ff87e9a6","ref":"f84e1d23d9469af9"},"argument_keys":["max_relations","ref"],"duration_ms":6.36,"event":"mcp.call","kind":"tool","name":"get_asset","outcome":"ok","principal":{"id":"local","roles":["default","local"]},"request_id":null,"sensitivity":"confidential","transforms":{"pseudonymized":{"aws_account_id":1,"aws_arn":1,"cloudg_uri":1,"dataset_name":1,"person":2,"resource_name":5,"tag_value":1},"untrusted":{"action":"fence","paths":["tags.Description"],"sanitized_fields":1,"stripped_chars":2,"suspicious":1,"notice":"..."},"annotations":{"...":"..."},"content_annotations":{"audience":["user","assistant"],"priority":0.9}},"ts":"2026-10-08T15:14:04.159+00:00"}
{"argument_keys":[],"duration_ms":0.05,"error":{"code":-31001,"type":"AccessDeniedError"},"event":"mcp.call","kind":"tool","name":"map_inventory","outcome":"denied","principal":{"id":"local","roles":["default","local"]},"request_id":null,"sensitivity":"confidential","ts":"2026-10-08T15:14:04.160+00:00"}
{"argument_hashes":{"query":"435634ff42bf31cb"},"argument_keys":["query"],"duration_ms":0.2,"error":{"code":-32602,"type":"InvalidArgumentsError"},"event":"mcp.call","kind":"tool","name":"find_assets","outcome":"rejected","principal":{"id":"local","roles":["default","local"]},"request_id":null,"sensitivity":"confidential","ts":"2026-10-08T15:14:04.161+00:00"}

(The first line's untrusted.notice and annotations are shortened here. That call used max_relations: 0, hence 5 resource_name replacements instead of 25.) Fields: ts (UTC, milliseconds), event (mcp.call), kind, name, principal, request_id (the JSON-RPC id over HTTP), argument_keys, argument_hashes (16-hex HMAC-SHA256 of the canonical JSON of each value), duration_ms, outcome (ok, error, denied, rate_limited, rejected, cancelled, exception), sensitivity, error (type and code, or the error code of an isError result) and transforms (the transform report). Over HTTP the principal is the token's principal, for example {"id": "ann", "roles": ["analyst", "default"]}. Completions are recorded too, with kind completion. Calls to hidden or unknown tools fail before the middleware runs, so only the policy log (15.1) has them.

Both trails hash the arguments as the client sent them, which under strict means the pseudonyms, before they were reversed. Set CLOUDG_MCP_AUDIT_SALT if you want hashes that are comparable across restarts.