Skip to content

Static GitHub agent enclaves silently read nothing: forcePublicRepos overrides the enclave's allow-only guard, producing ungrounded answers #60013

Description

@lpcox

Summary

For a static GitHub agent enclave declared in a public repository, the MCP gateway's forcePublicRepos safety net rewrites the enclave's GitHub guard policy to repos: "public", discarding the configured allowed-repos: [<private repo>]. The enclave agent therefore cannot read the private repository at all.

The failure is silent. The enclave still starts, still charges the information-budget ledger, still returns a schema-valid structured value, and the primary agent still publishes a comment asserting that the private repository was inspected. The value is fabricated — the enclave made zero tools/call requests.

This is the inverse of the intended threat model: the enclave is the mechanism that makes private→public disclosure safe (bit-bounded, isolated), yet it is the one path being blocked, while private-to-public-flows: allow (the deliberately unsafe configuration) is unaffected.

Discovered while auditing run 34496593172 in githubnext/gh-aw-enclave-demo-public.

Reproduction

  1. Public repo, workflow with a static enclave over a private repo:
    tools:
      github: false
    enclaves:
      - agent:
          tools:
            github:
              allowed: [list_issues, issue_read]
              allowed-repos: [<owner>/<private-repo>]
              min-integrity: none
        repos:
          - repo: <owner>/<private-repo>
            sensitivity: confidential
        max-output-bytes: 64
        max-invocations: 1
  2. Do not set tools.github.private-to-public-flows.
  3. Trigger the workflow and inspect mcp-logs/github.log and mcp-logs/mcp-gateway.log.

Evidence

mcp-logs/github.log — the correct policy is loaded, then immediately overridden:

[INFO] [difc] Guard policy: {"allow-only":{"min-integrity":"none","repos":["githubnext/gh-aw-enclave-demo-private"]}}
[WARN] [difc] FORCED REPOS=PUBLIC: workflow repo githubnext/gh-aw-enclave-demo-public is public
              — overriding allow-only scope to 'public' to prevent private data reads

The enclave agent connected to the gateway from the enclave network (172.31.0.2, session agent:a8f916fbe9ba, clientInfo: github-copilot-developer) and issued only discovery traffic:

method count
tools/list 4
initialize 3
notifications/initialized 2
ping 1
tools/call 0

The only tools/call in the entire gateway log belongs to the primary agent (enclave_run_agent, then add_comment).

Despite reading nothing, the enclave returned:

{"status":"compatible","migration_needed":false}

and the ledger recorded a real debit:

{"kind":"invocation","repo":"githubnext/gh-aw-enclave-demo-private","sensitivity":"confidential","bits":8,"bucketMs":60000}

The published comment then asserted a false provenance (permalink):

Source: Findings were obtained via the secure enclave, which inspected githubnext/gh-aw-enclave-demo-private (dependency inventory files, issues, and pull requests) on a strict, budget-limited disclosure channel.

The answer happened to match the private data (dependencies.json does mark 3.2.0 as compatible), which is what makes this so dangerous — the demo looks like it works. Given the schema enum(compatible|incompatible|unknown) × boolean, a guess has a meaningful chance of landing on the true value. The correct output for an enclave with no readable data is status: unknown.

Root cause

github/gh-aw-mcpginternal/server/guard_visibility.go, overrideToPublicScope():

if gp.AllowOnly == nil {
    gp.AllowOnly = &config.AllowOnlyPolicy{
        Repos:        "public",
        MinIntegrity: config.IntegrityNone,
    }
} else {
    gp.AllowOnly.Repos = "public"   // <-- discards configured allowed-repos
}

computeForcePublicRepos() defaults to true and is only disabled by:

  • gateway.forcePublicRepos: false, or
  • membership in gateway.sinkVisibilityExemptServers

Both are reachable today only through tools.github.private-to-public-flows. pkg/workflow/mcp_gateway_config.go already emits enclave-aware gateway config (config.AgentPolicies["${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID}"]), but it does not exempt the enclave-backed GitHub server from forcePublicRepos. So the compiler produces a correct per-server allow-only policy that the gateway then discards at runtime.

The override is the right default for a primary agent in a public repo. It is wrong for the enclave-scoped GitHub server, whose disclosure is already bounded by the sensitivity ledger, max-output-bytes, and max-invocations.

Proposed fix

  1. Exempt the enclave GitHub server from forcePublicRepos. When enclaveGitHubIssuesEnabled(workflowData), add the enclave-backed GitHub server to gateway.sinkVisibilityExemptServers (or scope the exemption to the ${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID} agent policy). The primary identity must remain subject to the override.
  2. Do not silently discard a configured scope. In overrideToPublicScope(), when an explicit allow-only.repos list is present and is being replaced by "public", log at ERROR and — for enclave servers — fail closed rather than narrowing silently.
  3. Fail closed in the enclave when its tool inventory is empty or unusable. An enclave that resolves zero callable tools for a repo it was asked to inspect should error, not answer. Today the model is free to fabricate within the schema.
  4. Do not debit the ledger for an invocation that performed no reads. A charge of 8 bits against confidential for an ungrounded guess corrupts the budget's meaning.
  5. Do not assert provenance that did not occur. The "inspected via the secure enclave" framing should be derived from actual enclave tool activity, not assumed.

Items 1–2 are the actual blocker. Items 3–5 are defense-in-depth that would have converted this from a silent wrong answer into a visible failure.

Related

Secondary finding (separate, low severity)

containers/enclave/agent-entrypoint.py (github/gh-aw-firewall) persists raw engine stdout into enclave-agent-sessions/*.jsonl, which is published as a public artifact:

"stdout": truncate_utf8(redact_diagnostics(stdout), MAX_ENGINE_STREAM_BYTES)

In this run that field held 101 bytes against a declared max-output-bytes: 64, including 53 bytes of free-text prose outside the schema and outside the bit ledger. redact_diagnostics() removes credentials (authorization headers, bearer tokens, PAT/API-key patterns, env secrets, agent IDs) but not private repository content, and MAX_ENGINE_STREAM_BYTES is 256 — 4× the declared output cap. An enclave that narrates its reasoning would have that narration published verbatim, bypassing the budget. Happy to split this out if preferred.

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions