dtwo Policy Store

Read-Only GitHub for Non-Engineers

Establishes the least-privilege baseline for the GitHub MCP connector on the agent channel.

Direction
ingress
Rego package
github.ingress.role_gate_writes_engineering
App
github
Bundles
soc2sox
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:298231c9480137bc89f70d5708ca8650a20bd01da91a16475c2d6c73c880f425

githubrole-gate-writesingresssoc2sox

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny gated writes, allow otherwise Package: github.ingress.role_gate_writes_engineering

What it does

Establishes the least-privilege baseline for the GitHub MCP connector on the agent channel. It denies the enumerated write and destructive GitHub tools (the suffix list below) unless the caller's IdP groups include engineering, while leaving all read tools (get_*, list_*, search_*, and the consolidated *_read tools) available to everyone.

At ingress — before the call reaches the GitHub MCP server, so a blocked write never executes and produces no side effect — the policy:

  • Allows any tool for callers in the engineering group. Membership is read from the IdP-issued JWT via object.get(input.subject, "claims", {}) then groups. Group matching is case-insensitive (Engineering == engineering).
  • Allows any non-gated tool for everyone. Read tools are never gated, so every caller keeps read access to code, issues, pull requests, and search.
  • Denies the enumerated write/destructive tools for everyone else. The default-deny takes effect when the caller is not in engineering and the tool matches a gated write suffix.

Group membership fails closed: a caller with no subject, no claims, or no groups claim resolves to an empty group set and is therefore treated as read-only.

Compliance alignment

  • SOC 2 CC6.1 — logical access security over protected assets: restricts who can mutate source code and change tooling on the agent channel (family PF-12, Enforceable).
  • SOC 2 CC6.3 — role-based access, least privilege, and separation of duties: write capability is bound to the engineering IdP group; everyone else is read-only (PF-12, Enforceable).
  • SOC 2 CC6.2 — authorize/de-provision credentials: gating on live IdP group claims means a de-provisioned or reassigned user loses write access as soon as their token stops asserting engineering (PF-12, Partial).
  • SOC 2 PI1.2 — inputs complete, accurate, and authorized: only authorized (engineering) principals may create or update repository content (PF-12, Partial).
  • SOX ITGC — access to programs and data — least-privilege access to the systems that hold financial-application source code and CI change tooling (PF-12, Enforceable).
  • SOX SoD (COSO Principle 10) — supports separation of initiate-vs-approve: the read-only default keeps non-engineers out of the change path (PF-12, Partial; pair with the merge/approval policy for the approval half).

Why ingress and not egress

Writes have permanent, externally visible side effects — a pushed file, a created branch, a triggered CI run, a filed issue visible to every repo watcher. Egress can only mask the response after the mutation already happened. Denying at ingress is the only way to actually prevent the unauthorized write.

Tool name matching

The gateway prefixes tool names with the configured MCP server name (e.g. github-mcp-create_or_update_file), and that prefix is not standardized, so the policy matches on the suffix of the lower-cased input.resource.name for portability. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.

Gated write/destructive suffixes (official github/github-mcp-server): create_or_update_file, push_files, delete_file, create_branch, create_repository, fork_repository, issue_write, sub_issue_write, add_issue_comment, create_pull_request, update_pull_request, update_pull_request_branch, pull_request_review_write, add_comment_to_pending_review, add_reply_to_pull_request_comment, discussion_comment_write, label_write, projects_write, create_gist, update_gist, actions_run_trigger, assign_copilot_to_issue, create_pull_request_with_copilot.

Also gated (archived community @modelcontextprotocol/server-github): create_issue, update_issue, create_pull_request_review — the archived server uses granular one-tool-per-operation names instead of the consolidated *_write tools, so these are included to keep the baseline holding on brownfield installs.

Read tools are identified only by exclusion: anything not matching a gated suffix is allowed for everyone. Because matching is by suffix, a single rule on issue_write also covers sub_issue_write; both are listed explicitly for documentation.

Argument shape

This policy inspects only the principal (IdP groups) and the tool name — it reads no tool arguments. That makes it robust against argument-key tricks: there is no owner/repo/method/content field to spoof, and method- multiplexed tools (issue_write, pull_request_review_write, label_write, projects_write, sub_issue_write) are gated at the tool level, so every method they multiplex is denied for non-engineers regardless of the method argument.

Examples

Allowed — read tool, any caller

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "github-mcp-get_file_contents", "type": "tool" },
    "payload": {
      "name": "github-mcp-get_file_contents",
      "args": { "owner": "acme", "repo": "web", "path": "README.md" }
    }
  }
}

allow = true, no reason. (No subject/groups required for reads.)

Allowed — write tool, engineering caller

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "github-mcp-push_files", "type": "tool" },
    "subject": { "sub": "google-apps|dev@acme.ai", "claims": { "groups": ["engineering"] } },
    "payload": { "name": "github-mcp-push_files", "args": { "owner": "acme", "repo": "web" } }
  }
}

allow = true.

Denied — write tool, non-engineering (or unauthenticated) caller

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "github-mcp-create_or_update_file", "type": "tool" },
    "subject": { "sub": "google-apps|sales@acme.ai", "claims": { "groups": ["sales"] } },
    "payload": { "name": "github-mcp-create_or_update_file", "args": { "owner": "acme", "repo": "web" } }
  }
}

allow = false, reason = "Access denied: \github-mcp-create_or_update_file` is a write or destructive GitHub tool restricted to the `engineering` IdP group. …"`.

Composition

This is the connector's baseline posture. Layer these companions on top — the gateway ANDs all attached ingress policies, so each narrows further:

  • require-human-approval-merge (PF-15): denies merge_pull_request and approving pull_request_review_write submissions even for engineers. This baseline deliberately does not gate merge_pull_request — the merge policy owns that concern.
  • fence-scopes-org-allowlist (PF-23 / anti-exfil): confines owner/repo to the company org so an engineer cannot push to a personal or third-party repo with their token.
  • redact-secrets-egress (PF-02): redacts credentials from file-content and search responses on the read path that this policy leaves open — including the sensitive secret-scanning reads.
  • A public-exposure policy (PF-27) forcing private:true on create_repository and denying public create_gist/personal-namespace fork_repository.

Known limitations

  • engineering is a placeholder. Replace it with your own IdP's group name at import time — group names are placeholders, not shipped defaults.
  • groups claim must be an array of strings. The policy iterates input.subject.claims.groups as an array (the common Auth0/Okta/Entra shape). An IdP that encodes groups as a single space- or comma-delimited string, or under a namespaced claim (e.g. https://acme.com/groups), will not match — the caller would be treated as read-only. Adapt is_engineering to your claim shape; confirm the actual shape with the dump-input technique or dtwo-list-claims.
  • Only enumerated write suffixes are gated. Other mutating tools not in the list — merge_pull_request (owned by the merge policy), notification writes (dismiss_notification, mark_all_notifications_read, manage_notification_subscription, manage_repository_notification_subscription), star_repository/unstar_repository, request_copilot_review — are not gated by this policy and pass through for non-engineers. (The notification-subscription and star tools are deliberately treated as low-risk and left ungated; request_copilot_review only requests a Copilot review and does not hand a code-writing task to an autonomous agent the way the gated assign_copilot_to_issue / create_pull_request_with_copilot do. The red-team pass added the PR-content/PR-review writes update_pull_request_branch, add_comment_to_pending_review, and add_reply_to_pull_request_comment, and the public/watcher-visible discussion_comment_write, to the gated set above after finding they slipped through.) Add their suffixes to gated_write_suffixes if your posture requires it, or rely on the sibling policies that own them. mark_all_notifications_read ends in _read but is a write; it is intentionally left ungated here (it is not in the enumerated set).
  • Tool inventory drifts. GitHub adds toolset tools over time; a newly introduced write tool with a suffix not on the list would be allowed for everyone until added. This is the blocklist trade-off; pair with a default-deny-unknown-tools (PF-28) allowlist policy if you need drift-proof coverage.
  • Placeholder-claim trust boundary. Group membership is only as trustworthy as the IdP that issued the JWT and the gateway's jwt_audience validation. is_admin, teams, and the internal user claim are stripped by the gateway and are deliberately not used here.

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.role_gate_writes_engineering

# Least-privilege baseline for the GitHub MCP connector.
# Deny-by-default: a request is permitted only by an explicit allow rule below.
default allow := false

# --- Gated write / destructive tool suffixes (official github/github-mcp-server) ---
# The gateway prefixes tool names with the configured MCP server name
# (e.g. `github-mcp-create_or_update_file`), so we match by suffix for
# portability across server naming conventions.
gated_write_suffixes := {
	"create_or_update_file",
	"push_files",
	"delete_file",
	"create_branch",
	"create_repository",
	"fork_repository",
	"issue_write", # a suffix match on this also covers `sub_issue_write`
	"sub_issue_write",
	"add_issue_comment",
	"create_pull_request",
	"update_pull_request",
	"update_pull_request_branch", # sibling of update_pull_request; distinct suffix, must be listed separately
	"pull_request_review_write",
	"add_comment_to_pending_review", # PR-review write not covered by any other suffix
	"add_reply_to_pull_request_comment", # PR review-comment write; `add_issue_comment` suffix does not cover it
	"discussion_comment_write", # public/watcher-visible content write; parity with add_issue_comment (red-team addition)
	"label_write",
	"projects_write",
	"create_gist",
	"update_gist",
	"actions_run_trigger",
	"assign_copilot_to_issue",
	"create_pull_request_with_copilot",
}

# --- Archived community server (@modelcontextprotocol/server-github) write names ---
# That server uses granular one-tool-per-operation names instead of the
# consolidated `*_write` tools; gated here so the baseline holds on brownfield
# installs.
archived_write_suffixes := {
	"create_issue",
	"update_issue",
	"create_pull_request_review",
}

# A tool is gated if its lower-cased name ends with any gated suffix.
is_gated_write_tool if {
	some suffix in gated_write_suffixes
	endswith(lower(input.resource.name), suffix)
}

is_gated_write_tool if {
	some suffix in archived_write_suffixes
	endswith(lower(input.resource.name), suffix)
}

# --- Identity: engineering group membership ---
# Group membership is read from the IdP-issued JWT claims. Fails closed: a
# missing `subject`, missing `claims`, or missing `groups` yields no match, so a
# caller with no groups claim is treated as read-only. Matching is
# case-insensitive. NOTE: `engineering` is a placeholder — replace it with your
# IdP's group name at import time.
is_engineering if {
	claims := object.get(input.subject, "claims", {})
	some group in object.get(claims, "groups", [])
	lower(group) == "engineering"
}

# --- Allow rules ---
# Engineering group members may call any GitHub tool.
allow if is_engineering

# Everyone may call any tool that is not a gated write/destructive tool. This
# leaves all read tools (get_*, list_*, search_*, *_read) available to all
# callers.
allow if not is_gated_write_tool

# --- Deny reason ---
# The only deny condition is a gated write by a non-engineering caller, so a
# single inline reason suffices.
reason := sprintf("Access denied: `%s` is a write or destructive GitHub tool restricted to the `engineering` IdP group. Read tools (get_*, list_*, search_*, *_read) remain available to everyone. Ask an admin to add you to the `engineering` group, or contact your platform team if this is a false positive.", [input.resource.name]) if not allow

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