---
name: "Read-Only GitHub for Non-Engineers"
tags:
  - github
  - role-gate-writes
  - ingress
  - soc2
  - sox
publishedAt: 2026-07-12
description: |
  # github / role-gate-writes-engineering

  **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

  ```jsonc
  {
    "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

  ```jsonc
  {
    "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

  ```jsonc
  {
    "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.
direction: ingress
apps:
  - github
industries: []
bundles:
  - soc2
  - sox
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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
```
