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

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions