dtwo Policy Store

Prevent Public Exposure of GitHub Repos, Gists & Forks

Stops the agent from exposing private code to the public across three GitHub write tools, at ingress — before the call reaches the GitHub MCP server, so a…

Direction
ingress
Rego package
github.ingress.deny_public_exposure_repos
App
github
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:7de86c061b5fe9c21fb3c8f21cabcb58ebf6722626bb94e7ed1625b1ce955722

githubdeny-public-exposureanti-exfilingresssoc2finserv-commseu-ai-act

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise (with a force-private transform on repo creation) Package: github.ingress.deny_public_exposure_repos

What it does

Stops the agent from exposing private code to the public across three GitHub write tools, at ingress — before the call reaches the GitHub MCP server, so a blocked publish never happens and a rewritten repo is created private:

  • create_gist — deny when it would be public. A gist whose public flag is set (public: true, or a "public" visibility value) is denied. Only an explicitly private/secret gist (public: false, "false", "private", or "secret") is allowed; a gist with no public flag is allowed (GitHub defaults gists to secret). The check fails closed on ambiguous visibility: an unrecognised public value (e.g. "yes", 1, null, an object) is treated as public and denied.
  • fork_repository — deny personal-namespace forks. A fork with no organization argument lands in the caller's personal namespace, escaping org controls and copying private code out of the sanctioned boundary. The policy denies fork_repository unless organization is present and a non-empty string. An absent, empty, or non-string organization fails closed and is denied.
  • create_repository — force private: true. Regardless of the requested private value, the policy rewrites the call so the repository is created private — whether the agent set private: false, private: true, or omitted the field. This is a transform applied on ingress, not a denial: the repo is still created, just never public.

All three tools are matched by suffix; every other call passes through unchanged. This is a security-hardening, anti-exfiltration control that complements the org-scope fence (fence-scopes-org-allowlist) and the secret-hygiene policies.

Compliance alignment

Per the Phase-3 coverage matrix, the deny-public-exposure family (PF-27) maps to the following controls on the MCP path. This policy is a boundary / anti-exfiltration deny control, so it belongs to the soc2 bundle; its FINRA and EU AI Act alignments are cited below as well, though those frameworks have no curated bundle in the current set.

  • SOC 2 CC6.6 — supports boundary protection against external exposure by stopping the agent from publishing private code to a public GitHub surface (a public gist, a public repository, or a personal-namespace fork); CC6.7 — supports the restriction on the movement/removal of confidential information by forcing new repositories private and denying public gists and personal forks, so source code cannot leave the sanctioned org boundary over the agent channel.
  • FINRA Rule 2210(b)(1) (principal pre-approval of retail communications) — supports keeping an agent from publishing to the public without human sign-off: a public gist or public repository authored by the agent is an unreviewed public communication, and this policy forces it private or blocks it so a human retains the publish decision.
  • EU AI Act Art. 50(4) (disclosure for AI-generated content made public) — supports the human-review marker on published output by preventing the agent from pushing content to a public GitHub surface (public repo/gist/ personal-namespace fork) on its own.

Why ingress and not egress

Publishing a public gist, creating a public repository, and forking private code into a personal namespace are writes with permanent, externally visible side effects — once the call reaches GitHub the content is public and may already be cloned, cached, or indexed. Egress redaction could only mask the response returned to the agent, not un-publish the code. Ingress denial (and the ingress force-private transform) is the only way to actually prevent the exposure.

Tool name matching

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. github-mcp-create_gist), and that prefix is not standardized. The policy therefore matches on the suffix, case-insensitively, on both the PARC resource.name and the legacy payload.name alias (so a call missing one of the two cannot slip past):

  • *create_gist — official server's gist-creation tool.
  • *fork_repository — same name on both the official (github/github-mcp-server) and archived (@modelcontextprotocol/server-github) servers, so one suffix covers both.
  • *create_repository — same name on both servers.

All three names are verified in the GitHub landscape note. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

Read from input.payload.args:

  • create_gist.public — the gist visibility flag. Handled as boolean (true/false) or string ("public"/"private"/"secret"/"false"), compared after trim_space + lower. Any present value that is not a recognised private value is treated as public (fail closed).
  • fork_repository.organization — the destination org login. Must be a present, non-empty string for the fork to be allowed.
  • create_repository.private — the requested visibility. Ignored for the decision; the transform sets private: true and preserves every other argument (name, description, organization, autoInit) via object.union.

The create_gist and create_repository argument schemas were not verified from source in the landscape pass (only create_repository's field list is documented, and fork_repository's organization is documented) — so confirm the live tools/list before pinning field names. If your server names the gist visibility field visibility rather than public, extend the accessor (see Known limitations).

Examples

Allowed — secret gist

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "github-mcp-create_gist", "type": "tool" },
    "payload": {
      "name": "github-mcp-create_gist",
      "args": { "description": "scratch", "public": false, "files": {} }
    }
  }
}

allow = true, no reason.

Denied — public gist

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "github-mcp-create_gist", "type": "tool" },
    "payload": {
      "name": "github-mcp-create_gist",
      "args": { "description": "leak", "public": true, "files": {} }
    }
  }
}

allow = false, reason tells the agent to create a secret gist instead.

Denied — personal-namespace fork

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "github-mcp-fork_repository", "type": "tool" },
    "payload": {
      "name": "github-mcp-fork_repository",
      "args": { "owner": "acme-inc", "repo": "billing" }
    }
  }
}

allow = false, reason asks for an organization inside a sanctioned org.

Transformed — repo forced private

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "github-mcp-create_repository", "type": "tool" },
    "payload": {
      "name": "github-mcp-create_repository",
      "args": { "name": "new-service", "private": false, "autoInit": true }
    }
  }
}

allow = true; transform rewrites private to true and preserves name and autoInit — the repository is created private.

Composition

This policy is single-purpose (three related public-exposure surfaces). Recommended companions (see the GitHub landscape note's candidate list):

  • fence-scopes-org-allowlist (PF-23) — fences the owner argument to the company org, closing the push_files / create_or_update_file write-exfil path this policy does not touch.
  • role-gate-writes (PF-12) — restrict all write tools to an engineering IdP group so non-engineers get read-only GitHub.
  • An egress secret-hygiene / IP-redaction policy on get_file_contents, search_code, and pull_request_read responses.

Known limitations

  • Only the create_gist, fork_repository, and create_repository surfaces are covered. Other public-exposure paths — pushing to a public repo the OAuth grant can reach, transferring a repo, or toggling an existing repo public via a settings tool — are out of scope. Pair with the org-scope fence and role-gate policies.
  • Gist visibility field name is assumed. The policy inspects the public argument. create_gist's exact MCP schema was not verified from source in the landscape pass; if your server exposes visibility under a different key (e.g. visibility), a public gist could slip through. Confirm with the live tools/list and extend the gist_public_value accessor.
  • create_repository transform is top-level only. It pins the top-level private field. If a server nests the repo definition under another key, the nested visibility is not rewritten. The documented official/archived servers take private at the top level.
  • Malformed non-object args on create_repository pass through un-rewritten. object.union is undefined on a non-object, so no transform fires; such a call carries no valid repository definition and fails at the GitHub server (documented residual, covered in tests). It cannot create a public repo.
  • Suffix matching misses a trailing segment after the tool name. A tool named e.g. ...create_gist-v2 would not match create_gist and would pass through. The gateway only prepends the configured server name, so this does not affect the real servers; confirm exact names with dump-input and extend the suffix set if your server differs.
  • Fork destination org is presence-checked, not allowlisted. fork_repository is allowed whenever organization is any non-empty string. The policy cannot distinguish a sanctioned company org from an attacker-created free org, so a fork into an arbitrary org the caller controls is permitted (covered in tests). The deny reason says "a sanctioned company org" as user guidance, but sanctioning is not enforced here. To fence the destination to specific orgs, pair with an org-allowlist policy (PF-23 fence-scopes-org-allowlist) or add an allowlist-membership check to fork_has_org (allowed_orgs[lower(trim_space(org))] against a per-tenant set constant).
  • No identity-based exemption. Every caller is subject to the same controls. If you need a break-glass identity that may create public repos or gists, add an allow if branch keyed on input.subject.claims.groups with a documented placeholder group name (replace it with your IdP's group name at import time).

Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.

Policy source (Rego)

package github.ingress.deny_public_exposure_repos

# Deny-by-default: a call is permitted only by an explicit allow rule below.
# `create_repository` is always allowed (never denied) and force-rewritten to
# private by the transform; the gist and fork surfaces deny on public exposure.
default allow := false

# --- Shared accessors --------------------------------------------------------

# Tool name from the PARC resource.name and the legacy payload.name alias, each
# lower-cased. Both are read (matched separately below) so a call that arrives
# with one of the two absent cannot slip past the suffix match.
resource_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

payload_name := lower(object.get(object.get(input, "payload", {}), "name", ""))

# Tool arguments; {} when absent so downstream object.get never errors.
args := object.get(object.get(input, "payload", {}), "args", {})

# --- Tool matching (suffix, case-insensitive, portable across server prefixes) ---

is_create_gist if endswith(resource_name, "create_gist")

is_create_gist if endswith(payload_name, "create_gist")

is_fork if endswith(resource_name, "fork_repository")

is_fork if endswith(payload_name, "fork_repository")

is_create_repo if endswith(resource_name, "create_repository")

is_create_repo if endswith(payload_name, "create_repository")

# --- create_gist: deny public / ambiguous visibility -------------------------

# Recognised private/secret visibility strings. Anything else present is treated
# as public (fail closed).
recognized_private_strings := {"false", "private", "secret"}

# The gist's `public` argument value; undefined when the key is absent (or when
# args is not an object). Separated from the presence check because the value
# may legitimately be the boolean `false`.
gist_public_value := args.public

# The `public` key is present (even if its value is `false` or `null`).
gist_public_present if {
    _ = args.public
}

# The gist is explicitly marked private/secret -> safe, allowed.
gist_marked_private if {
    gist_public_value == false
}

gist_marked_private if {
    is_string(gist_public_value)
    recognized_private_strings[lower(trim_space(gist_public_value))]
}

# Public exposure: a create_gist whose `public` flag is present but is NOT a
# recognised private value. Covers public:true, "public", and any unrecognised
# value (fail closed). A create_gist with no `public` flag is not an exposure.
gist_public_exposure if {
    is_create_gist
    gist_public_present
    not gist_marked_private
}

# --- fork_repository: deny personal-namespace forks --------------------------

# The fork targets an organization when `organization` is a present, non-empty
# string. Absent, empty, or non-string organization fails closed (denied).
fork_has_org if {
    org := object.get(args, "organization", "")
    is_string(org)
    trim_space(org) != ""
}

fork_personal_namespace if {
    is_fork
    not fork_has_org
}

# --- Allow rules -------------------------------------------------------------

# A call is permitted unless it is a public-gist exposure or a personal-namespace
# fork. Expressed as the negation of the two deny conditions (not one allow
# branch per governed tool) so a call whose resource.name and payload.name carry
# DIFFERENT governed suffixes cannot use a never-denied create_repository arm to
# override a gist/fork denial. Non-governed tools and create_repository trip
# neither condition and pass through; create_repository is force-rewritten to
# private by the transform below.
allow if {
    not gist_public_exposure
    not fork_personal_namespace
}

# --- Transform: force create_repository private ------------------------------

# Regardless of the requested `private` value, pin private:true and preserve
# every other argument. Guarded on is_object so a malformed non-object args
# passes through unmodified (documented residual — it fails at the server).
transform := {"transformed_payload": object.union(args, {"private": true})} if {
    is_create_repo
    is_object(args)
}

# --- Deny reasons ------------------------------------------------------------

reasons contains "Creating a public gist is blocked to prevent private code from being exposed publicly. Create a secret gist instead (set public: false), or share the snippet through a repository inside your company org. Contact your InfoSec team if this gist genuinely needs to be public." if {
    gist_public_exposure
}

reasons contains "Forking into a personal namespace is blocked because it copies repository content outside your organization's controls. Re-run the fork with an organization set to a sanctioned company org so the fork stays inside the org boundary. Contact your InfoSec team if you need a personal fork for a legitimate reason." if {
    fork_personal_namespace
}

reason := joined if {
    count(reasons) > 0
    reason_list := sort([r | some r in reasons])
    joined := concat("; ", reason_list)
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)

Used in these guides