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:
sink-visibility (write-sink guard policy field) — blocks tainted agents from writing to public sinks
- 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:
- Forced
repos="public" → agent can't read private data (input filtering)
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:
- Validate: If
guards_mode is strict, emit compile error
- Emit config: Set
gateway.forcePublicRepos: false and gateway.sinkVisibilityExemptServers: ["*"]
- Skip sink-visibility: Do not set
sink-visibility: "public" even if target repo is public
- Audit trail: Log that the workflow author opted out of cross-visibility protection
For [server1, server2, ...]:
- Validate: Verify listed servers exist in
tools list
- Emit config: Set
gateway.sinkVisibilityExemptServers: ["server1", "server2"]
- Selective exemption: Listed servers don't get default
sink-visibility: "public" from gateway
- 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
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:
sink-visibility(write-sink guard policy field) — blocks tainted agents from writing to public sinksrepos="public"(runtime override) — prevents agents in public-repo workflows from reading private dataThese are complementary defenses for different scenarios:
repos="public"— agent never reads private datasink-visibility="public"— blocks tainted writes at the sinkKey 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 readsGITHUB_REPOSITORYto 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-visibilityto the write-sink guard policy section: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 —
acceptvssink-visibility:sink-visibilityis"public": theacceptarray 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.acceptis still syntactically required by validation but has no runtime effect.sink-visibilityis"private","internal", or omitted:acceptis the sole determinant of resource secrecy and controls which agents may write to the sink.Precedence:
sink-visibility: "public"is a hard override that trumpsaccept.Default sink-visibility for non-safe-outputs servers
Security-by-default: All write-sink servers that do NOT have an explicit
sink-visibilityconfigured are treated as ifsink-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-outputsserver gets itssink-visibilitydynamically from the compiler (based on the actual repo visibility via thedetermine-automatic-lockdownstep). When omitted onsafe-outputs, the legacy behavior applies (accept-pattern matching without visibility enforcement).Runtime logic:
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:
GITHUB_REPOSITORYand callsGET /repos/{owner}/{repo}to verify actual visibilitysink-visibilityconfig says otherwise → overrides to"public"with warningsink-visibilityis omitted on safe-outputs (preserves backward compat for that server)GITHUB_REPOSITORYor the GitHub token is unavailable"public"stays"public"even if repo is actually private2. 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 torepos="public". This forces the Rust WASM guard to:ScopeKind::Public→ noprivate:*labels)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:
repos="public"→ agent can't read private data (input filtering)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-flowsWorkflow front-matter
Workflow authors who intentionally allow private→public data flows can opt out. The field accepts two forms:
Allow all servers:
Allow specific servers only (list form):
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 defaultsink-visibility: "public"; forced repos=public and other servers' enforcement remain activeConstraints:
allowis incompatible withguards_mode: strict— the compiler MUST reject this combination at compile timeGateway-level config
The compiler translates
private-to-public-flowsinto 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=falseThe
sinkVisibilityExemptServersfield tells the gateway which servers should NOT receive the defaultsink-visibility: "public"when their configured sink-visibility is omitted.Validation (both compile-time and runtime):
private-to-public-flowsthat does not appear in the workflow'stools:list (except"*").sinkVisibilityExemptServers(except"*") matches a key in themcpServersconfig. 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 defaultInteraction matrix
guards_modeprivate-to-public-flowsstrictallowstrict[servers...]strictfilter/propagateallowfilter/propagate[servers...]filter/propagateCompiler responsibilities
When the compiler encounters
private-to-public-flows:For
allow:guards_modeisstrict, emit compile errorgateway.forcePublicRepos: falseandgateway.sinkVisibilityExemptServers: ["*"]sink-visibility: "public"even if target repo is publicFor
[server1, server2, ...]:toolslistgateway.sinkVisibilityExemptServers: ["server1", "server2"]sink-visibility: "public"from gatewaySecurity rationale
This opt-out exists for workflows that legitimately need to:
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
sink-visibilityin write-sink guard policy based on target repo visibility #44153 — Compiler: emit sink-visibility