agentleFS
sirkirbysirkirby/unifi-network-mcp/.agents/skills/mcp-response-redaction-governance/SKILL.md

myco:mcp-response-redaction-governance

Skill979 starsChanged 19 days agoMITView on GitHub

Use this skill whenever you're adding, changing, or reviewing sensitive-field disclosure behavior on an MCP tool or API response — even if the user just asks to "expose this field" or "let callers opt into raw data." Covers: configuring per-surface redaction via redact_sensitive_fields and its env-var hierarchy (should_redact_sensitive_fields in unifi-core), the two real, separately-owned policy mechanisms in unifi-core (PolicyGateChecker for mutation gates, the redaction resolver for response disclosure) and how to extend either for a new domain, the two distinct enforcement points (per-tool/per-route redaction application vs. the StrictKwargFastMCP.call_tool write-back marker guard), and migrating legacy include_sensitive-style caller flags to server-owned policy. Trigger this any time a change would let a request argument control whether secrets/tokens/sensitive fields are disclosed in a response — that decision belongs to server/operator policy, never to the caller.

We split this skill into the rules it gives an agent

14 rules. About 0 are common, 6 are distinctive, and 8 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈6

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Do not assume a single unified "PolicyEngine" class exists.

    line 38
  2. Distinctive:

    Keep this convention when adding new redaction toggles.

    line 60
  3. Very specific:

    Default: true (redact) if nothing is set — safe-by-default

    line 71
  4. Distinctive:

    When adding a new surface, add its per-surface env var to the same hierarchy rather than hardcoding a local redaction flag, and decide …

    line 92
  5. Very specific:

    Put the resolver/checker itself in packages/unifi-core so every product package shares one implementation.

    line 125
  6. Very specific:

    If a shared per-surface convenience wrapper is useful — as should_redact_response_sensitive_fields in …

    line 127
  7. Very specific:

    Every read tool/route resolves the boolean early and passes it explicitly into redact_sensitive_fields(payload, redact_sensitive=...) …

    line 150
  8. Very specific:

    When reviewing a redaction-related change, check both mechanisms independently: does every new read path call redact_sensitive_fields(), …

    line 189
  9. Very specific:

    If you find an existing tool/API parameter that lets a caller request raw/unredacted data directly (e.g.

    line 197
  10. Distinctive:

    Remove the caller-controlled parameter from the public tool schema / API request shape entirely — do not just gate it, remove it.

    line 201
  11. Distinctive:

    If the surface still needs to reject a caller attempting to pass the old parameter (rather than silently ignoring it), return an explicit …

    line 214
  12. Distinctive:

    Safe-by-default: if no policy signal is present anywhere in the hierarchy, the system must default to redacting.

    line 235
  13. Distinctive:

    Test both independently when changing either.

    line 241
  14. Very specific:

    Don't invent a third policy shape: before adding any new server-side policy check, decide whether it's an allow/deny gate …

    line 242

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

MCP Response Redaction Governance

This domain covers how sensitive-field disclosure (secrets, tokens, raw credentials) is controlled across MCP tools and API responses. The governing principle: request intent is not authorization. A caller argument like include_sensitive=true expresses what the caller wants, but only server/operator policy decides whether to honor it. Never let a per-call argument directly gate secret disclosure.

Prerequisites

  • Know which surface you're touching: an MCP product server (network/access/protect) or the REST API (api). Each surface has its own env-var prefix and resolves the redaction decision at a different point in the request lifecycle (see Procedure A).
  • Do not assume a single unified "PolicyEngine" class exists. Check packages/unifi-core/src/unifi_core/policy.py and packages/unifi-core/src/unifi_core/policy_gate.py directly — there are two separate mechanisms, not one (see Procedure B).
  • Locate the correct enforcement point for what you're changing: read-path redaction and the mutation write-back guard are two different checks in two different files (see Procedure C). Don't assume one covers the other.

Procedure A: Configuring redaction policy per surface

Sensitive-field disclosure is controlled by a boolean, named for the enforced behavior rather than a data format:

policy:
  response:
    redact_sensitive_fields: true  # default; true = redact secrets

Naming rationale: redact_sensitive_fields: true|false is clearer than alternatives like sensitive_fields: redacted|raw, because the operator is deciding whether the server redacts, not choosing an output format. Keep this convention when adding new redaction toggles.

The boolean is resolved by should_redact_sensitive_fields(server_prefix, config, env) in packages/unifi-core/src/unifi_core/policy.py, in this order (highest specificity wins):

  1. UNIFI_{SURFACE}_REDACT_SENSITIVE_FIELDS — per-surface env override (e.g. UNIFI_NETWORK_REDACT_SENSITIVE_FIELDS, UNIFI_API_REDACT_SENSITIVE_FIELDS)
  2. UNIFI_REDACT_SENSITIVE_FIELDS — global env fallback
  3. policy.response.redact_sensitive_fields from the surface's own config
  4. Default: true (redact) if nothing is set — safe-by-default

Resolution timing differs by surface — check this before assuming a live env-var change takes effect:

  • MCP surfaces: each app defines a local should_redact_sensitive_fields() -> bool binding function in its own runtime module — e.g. apps/network/src/unifi_network_mcp/runtime.py, apps/access/src/unifi_access_mcp/runtime.py, apps/protect/src/unifi_protect_mcp/runtime.py — that calls the shared resolver on every invocation. Because MCP tool functions call this binding at the top of each call, an env-var change takes effect on the next tool call with no restart.
  • API surface: apps/api/src/unifi_api/config.py calls should_redact_sensitive_fields("api", config=container) once, inside load_config(), and caches the result on ApiConfig.policy.response .redact_sensitive_fields. Routes then read that cached attribute per request. Changing UNIFI_API_REDACT_SENSITIVE_FIELDS or UNIFI_REDACT_SENSITIVE_FIELDS for the API surface requires a process restart to take effect — it is not re-resolved per request.

When adding a new surface, add its per-surface env var to the same hierarchy rather than hardcoding a local redaction flag, and decide deliberately whether the new surface resolves per-call (like MCP) or at config-load time (like the API) — don't silently inherit whichever pattern is closest by copy-paste.

Procedure B: Extending policy for a new domain — pick the right existing shape

There is no single unified policy engine class. Two separate mechanisms live in unifi-core, and each new policy question should be modeled as one more instance of whichever shape fits, not a third bespoke mechanism:

  1. Gate checks (PolicyGateChecker in packages/unifi-core/src/unifi_core/policy_gate.py) answer "is this category/action combination allowed?" via a 3-level env-var hierarchy: UNIFI_POLICY_<SERVER>_<CATEGORY>_<ACTION> → UNIFI_POLICY_<SERVER>_<ACTION> → UNIFI_POLICY_<ACTION> → allowed by default. Use this shape for new allow/deny gates on categorized actions (mutation authorization is the existing example via .check(category, action)).
  2. Resolvers (should_redact_sensitive_fields in packages/unifi-core/src/unifi_core/policy.py) answer "what is the value of this policy setting for this surface?" via the per-surface → global → config → default hierarchy described in Procedure A. Use this shape for a typed value (not just allow/deny) that varies by surface.

To add a new domain:

  1. Decide whether the question is a categorized allow/deny gate or a surface-scoped value resolver, and follow the matching precedent file above rather than writing a new env-var precedence scheme from scratch.
  2. Put the resolver/checker itself in packages/unifi-core so every product package shares one implementation.
  3. If a shared per-surface convenience wrapper is useful — as should_redact_response_sensitive_fields in packages/unifi-mcp-shared/src/unifi_mcp_shared/response_policy.py does for redaction — add it in packages/unifi-mcp-shared, and bind it per surface with a thin should_redact_sensitive_fields() -> bool function inside that surface's own runtime module (following apps/network/src/unifi_network_mcp/runtime.py as the template) rather than having every tool call the generic resolver with a hardcoded surface string.

Gotcha: design discussions for this domain have proposed unifying gate-checks and resolvers under one PolicyEngine/PolicyContext interface. That interface does not exist in the codebase today — verify with grep -r PolicyEngine before writing code or docs that assume it does, and don't reintroduce it without also migrating both existing mechanisms onto it in the same change.

Procedure C: Enforcing redaction and the write-back guard — two different checks

These are separate mechanisms enforced in separate places. Don't assume fixing one covers the other.

1. Read-path redaction (per-tool / per-route, not centralized): Every read tool/route resolves the boolean early and passes it explicitly into redact_sensitive_fields(payload, redact_sensitive=...) (defined in packages/unifi-core/src/unifi_core/redaction.py) when shaping its response:

# MCP tool (e.g. apps/network/src/unifi_network_mcp/tools/system.py)
redact_sensitive = should_redact_sensitive_fields()
return redact_sensitive_fields({...}, redact_sensitive=redact_sensitive)

# API route (e.g. apps/api/src/unifi_api/routes/actions.py)
redact_sensitive = request.app.state.config.policy.response.redact_sensitive_fields
shaped = redact_sensitive_fields(result.payload, redact_sensitive=redact_sensitive)

This call is not applied automatically by a middleware or dispatcher — every new read tool, route, serializer, or GraphQL resolver that returns a payload with potentially sensitive fields must call it explicitly. When adding a new read surface, grep sibling tools/routes in the same file for redact_sensitive_fields( to find the local pattern to follow, and confirm the new code path actually calls it (a missing call is the most likely regression, not a wrong env var).

2. Write-back marker guard (centralized in MCP transport): StrictKwargFastMCP.call_tool, implemented in packages/unifi-mcp-shared/src/unifi_mcp_shared/strict_dispatch.py, inspects incoming tool arguments via redaction_marker_paths() (defined in packages/unifi-core/src/unifi_core/redaction.py) and rejects any argument that still carries the redaction marker value, so a caller can't round-trip a previously-redacted response field back as if it were a real update. This is centralized — but it only covers the "marker submitted as new data" case, not the read-redaction decision above.

The API surface has an analogous but separate guard: it rejects any include_sensitive key present in the request args outright — see apps/api/src/unifi_api/services/actions.py and apps/api/src/unifi_api/routes/actions.py — rather than doing marker-pattern detection.

When reviewing a redaction-related change, check both mechanisms independently: does every new read path call redact_sensitive_fields(), and (for MCP) is the new tool's manifest/schema covered by the marker write-back check so a redacted field can't be replayed as a mutation input?

Procedure D: Migrating away from include_sensitive-style caller flags

If you find an existing tool/API parameter that lets a caller request raw/unredacted data directly (e.g. include_sensitive=true), migrate it to server-owned policy:

  1. Remove the caller-controlled parameter from the public tool schema / API request shape entirely — do not just gate it, remove it. Leaving it present but "gated" still collapses request-intent and authorization into one flag.
  2. Replace any internal use of the old flag name with redaction-oriented naming: should_redact_sensitive_fields(), should_redact_response_sensitive_fields(), redact_sensitive=True|False at the actual redaction boundary in redact_sensitive_fields(). Do not carry the old include_sensitive name forward as an internal helper pattern.
  3. Compute any internal "include sensitive" boolean, if still needed downstream, as not redact_sensitive_fields derived purely from server policy — never from a caller-supplied value.
  4. If the surface still needs to reject a caller attempting to pass the old parameter (rather than silently ignoring it), return an explicit error naming the correct replacement env var/config key — see the unsupported-argument error constant defined in apps/api/src/unifi_api/services/actions.py — instead of silently dropping the argument.
  5. Coordinate the change across every package that touches the response path (the shared policy resolver, each MCP product server, and the REST API) — a partial migration reintroduces the caller-controlled disclosure gap on the surfaces left behind.

Cross-Cutting Gotchas

  • Two mechanisms, not one: gate checks (PolicyGateChecker) and resolvers (should_redact_sensitive_fields) are independent code paths in unifi-core today. A fix or extension to one does not automatically apply to the other — check both when auditing policy coverage.
  • Resolution timing varies by surface: MCP resolves per call; the API surface resolves once at config-load time and caches it (see Procedure A). Don't assume a live env-var override works identically on every surface — verify against the surface's actual resolution point.
  • Safe-by-default: if no policy signal is present anywhere in the hierarchy, the system must default to redacting. Never default to disclosure.
  • Read-redaction vs. write-back guard are different checks: fixing a bug in the read-path redact_sensitive_fields() call site does not touch the StrictKwargFastMCP.call_tool marker guard, and vice versa. Test both independently when changing either.
  • Don't invent a third policy shape: before adding any new server-side policy check, decide whether it's an allow/deny gate (PolicyGateChecker shape) or a surface-scoped value (resolver shape), and extend the matching existing mechanism in packages/unifi-core.
  • Secret-vocabulary boundary vs. logging hygiene: CodeQL's clear-text-logging-sensitive-data alert rule, applied to Network client/device managers and tools, typically flags MAC-address logger arguments, not leaked passwords or tokens. unifi_core.redaction intentionally targets secret key material and leaves inventory identifiers (MAC addresses, serials) readable — that's a separate boundary from this domain's secret-field redaction pattern. Don't add inventory identifiers to the redaction vocabulary to silence these alerts; fix the logging call site instead.
  • Exception text is a disclosure surface too: error-classification / support-bundle code must never serialize a raw exception's text or status property back into a response — a hostile exception subclass can carry sensitive data via its message or attributes (test_error_classification_never_serializes_exception_text guards this). Treat exception serialization as another redaction-adjacent boundary alongside tool/route payloads, not something the read-path redact_sensitive_fields() call alone covers.

Historical context

This governance model was established while closing a reported weakness where a per-call caller-supplied flag could influence sensitive disclosure directly. The fix removed the caller-controlled parameter from public surfaces in favor of the redact_sensitive_fields config/env model in Procedure A, and added the write-back marker guard in Procedure C. Design discussion during that work proposed unifying mutation-gate and response-disclosure policy under one PolicyEngine/PolicyContext interface; that unification was the intended end-state but has not shipped — the codebase still has the two separate mechanisms described in Procedure B. Treat any reference to a single "PolicyEngine" as aspirational until it is actually merged, and verify against the unifi-core policy modules directly.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.