Skip to content

Spec: document sink-visibility field in MCP Gateway write-sink guard policy #44290

Description

@lpcox

Context

The MCP Gateway needs to prevent private data from leaking to public repositories through agentic workflows (the GitLost vulnerability class). Two complementary mechanisms were implemented in gh-aw-mcpg and need to be added to the formal spec:

  1. sink-visibility (write-sink guard policy field) — blocks tainted agents from writing to public sinks
  2. Forced repos="public" (runtime override) — prevents agents in public-repo workflows from reading private data

These are complementary defenses for different scenarios:

Workflow repo visibility Threat Defense
Public Agent's token can access private repos → reads private data → writes to the public workflow repo Forced repos="public" — agent never reads private data
Private Agent reads from its own private repo → prompt injection tricks it into writing to public output sink-visibility="public" — blocks tainted writes at the sink

Key assumption: This spec assumes that safe-outputs always targets the workflow's own repository (GITHUB_REPOSITORY). Under this assumption, "sink visibility" and "workflow repo visibility" are the same value — there is no separate "sink target" to resolve. The runtime verification reads GITHUB_REPOSITORY to determine both. If safe-outputs is ever extended to write to a different repo, this assumption and the runtime check would need revision.


1. Sink-visibility field (write-sink guard policy)

Spec addition (docs/src/content/docs/reference/mcp-gateway.md)

Add sink-visibility to the write-sink guard policy section:

sink-visibility (optional, string): Declares the visibility of the safe-outputs target repository
(which is always the workflow's own repo, i.e. GITHUB_REPOSITORY).

Values:
- "public"   — Target is a public repo; agents with non-empty secrecy are BLOCKED
                (resource secrecy set to empty, overriding accept patterns)
- "private"  — Target is a private repo; standard accept-pattern matching applies
- "internal" — Target is an org-internal repo; enforcement-equivalent to "private"
- (omitted)  — Backward compatible; standard accept-pattern matching applies

Enforcement model: Only "public" alters DIFC enforcement. Both "private" and "internal" are semantically equivalent — they both apply standard accept-pattern matching. The "internal" value exists for configuration fidelity (matching GitHub's three-tier visibility model) rather than distinct enforcement behavior. Implementers should treat any non-"public" value (including "internal" and omitted) as "use accept patterns normally."

Field interaction — accept vs sink-visibility:

  • When sink-visibility is "public": the accept array is ignored for enforcement — resource secrecy is unconditionally set to empty ({}). The DIFC write check (agentSecrecy ⊆ resourceSecrecy) fails for any agent with non-empty secrecy. accept is still syntactically required by validation but has no runtime effect.
  • When sink-visibility is "private", "internal", or omitted: accept is the sole determinant of resource secrecy and controls which agents may write to the sink.

Precedence: sink-visibility: "public" is a hard override that trumps accept.

Default sink-visibility for non-safe-outputs servers

Security-by-default: All write-sink servers that do NOT have an explicit sink-visibility configured are treated as if sink-visibility: "public" were set. This is because non-safe-outputs write sinks (e.g., Playwright, Slack, email) should be assumed to release data to the public by default.

Exception — safe-outputs: The safe-outputs server gets its sink-visibility dynamically from the compiler (based on the actual repo visibility via the determine-automatic-lockdown step). When omitted on safe-outputs, the legacy behavior applies (accept-pattern matching without visibility enforcement).

Runtime logic:

if server has write-sink policy AND sink-visibility is omitted:
    if serverID != "safe-outputs" AND private-to-public-flows does not exempt this server:
        effective sink-visibility = "public"
    else:
        effective sink-visibility = (omitted — use accept patterns)

JSON Schema (docs/public/schemas/mcp-gateway-config.schema.json)

Add a typed schema for write-sink inside guard-policies:

{
  "write-sink": {
    "type": "object",
    "properties": {
      "accept": {
        "type": "array",
        "items": { "type": "string" }
      },
      "sink-visibility": {
        "type": "string",
        "enum": ["public", "private", "internal"]
      }
    },
    "required": ["accept"]
  }
}

Runtime verification (defense-in-depth)

The gateway performs a runtime check at startup:

  • Reads GITHUB_REPOSITORY and calls GET /repos/{owner}/{repo} to verify actual visibility
  • If repo is public but sink-visibility config says otherwise → overrides to "public" with warning
  • Skipped when sink-visibility is omitted on safe-outputs (preserves backward compat for that server)
  • Skipped when GITHUB_REPOSITORY or the GitHub token is unavailable
  • Falls back to configured value on API errors (non-fatal)
  • Never relaxes: configured "public" stays "public" even if repo is actually private

2. Forced repos="public" (runtime override for public-repo workflows)

When the gateway detects it is running in a public repository (via GITHUB_REPOSITORY + API check), it overrides the allow-only policy to repos="public". This forces the Rust WASM guard to:

  • Assign zero secrecy tags to the agent (ScopeKind::Public → no private:* labels)
  • Filter out access to private repositories

This prevents the agent from accumulating private secrecy in the first place — even if the compiled config grants broader access.

Defense-in-depth: When the workflow repo is public, both mechanisms activate:

  1. Forced repos="public" → agent can't read private data (input filtering)
  2. sink-visibility="public" → tainted writes blocked (output filtering)

These are redundant by design — either alone would prevent the GitLost attack.


3. Opt-out: private-to-public-flows

Workflow front-matter

Workflow authors who intentionally allow private→public data flows can opt out. The field accepts two forms:

Allow all servers:

---
tools:
  - github
  - safe-outputs
  - playwright
private-to-public-flows: allow
---

Allow specific servers only (list form):

---
tools:
  - github
  - safe-outputs
  - playwright
private-to-public-flows:
  - playwright
  - slack
---

When a list is provided, only the named servers are exempted from the default sink-visibility: "public". All other write-sink servers still get the secure default.

Semantics:

  • allow (string) — disables sink-visibility enforcement AND forced repos=public for ALL servers
  • [server1, server2, ...] (list) — exempts only the listed servers from the default sink-visibility: "public"; forced repos=public and other servers' enforcement remain active

Constraints:

  • allow is incompatible with guards_mode: strict — the compiler MUST reject this combination at compile time
  • The list form IS compatible with strict mode (it's a targeted exemption, not a blanket disable)

Gateway-level config

The compiler translates private-to-public-flows into gateway config:

For allow (blanket disable):

{
  "gateway": {
    "forcePublicRepos": false,
    "sinkVisibilityExemptServers": ["*"]
  }
}

For list form (targeted exemption):

{
  "gateway": {
    "sinkVisibilityExemptServers": ["playwright", "slack"]
  }
}

Environment variable for blanket disable: MCP_GATEWAY_FORCE_PUBLIC_REPOS=false

The sinkVisibilityExemptServers field tells the gateway which servers should NOT receive the default sink-visibility: "public" when their configured sink-visibility is omitted.

Validation (both compile-time and runtime):

  • Compile time: The compiler MUST reject any server name in private-to-public-flows that does not appear in the workflow's tools: list (except "*").
  • Runtime: The gateway MUST validate that each entry in sinkVisibilityExemptServers (except "*") matches a key in the mcpServers config. Unknown server IDs produce a startup warning (non-fatal) and are ignored.

Values:

  • ["*"] — no server gets the default (equivalent to disabling the feature)
  • ["playwright", "slack"] — only those servers are exempted; all others get the default

Interaction matrix

guards_mode private-to-public-flows Forced repos=public Default sink-visibility=public
strict allow Rejected (compile error)
strict [servers...] ✅ Active ✅ Active (except listed servers)
strict (omitted) ✅ Active ✅ Active (all servers)
filter / propagate allow ❌ Disabled ❌ Disabled (all servers exempt)
filter / propagate [servers...] ✅ Active ✅ Active (except listed servers)
filter / propagate (omitted) ✅ Active ✅ Active (all servers)

Compiler responsibilities

When the compiler encounters private-to-public-flows:

For allow:

  1. Validate: If guards_mode is strict, emit compile error
  2. Emit config: Set gateway.forcePublicRepos: false and gateway.sinkVisibilityExemptServers: ["*"]
  3. Skip sink-visibility: Do not set sink-visibility: "public" even if target repo is public
  4. Audit trail: Log that the workflow author opted out of cross-visibility protection

For [server1, server2, ...]:

  1. Validate: Verify listed servers exist in tools list
  2. Emit config: Set gateway.sinkVisibilityExemptServers: ["server1", "server2"]
  3. Selective exemption: Listed servers don't get default sink-visibility: "public" from gateway
  4. Audit trail: Log which servers are exempted

Security rationale

This opt-out exists for workflows that legitimately need to:

  • Read from private repos and post summaries to public issue trackers
  • Write to external services (Playwright, Slack) that are not inherently "public" in the GitHub sense
  • Cross-post between private and public repos

The strict-mode incompatibility (for allow) ensures organizations requiring maximum security cannot accidentally enable the blanket escape hatch. The list form provides a safer, targeted alternative.


References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions