--- name: Fence GitHub Access to the Company Org Allowlist tags: - github - fence-sensitive-scopes - org-allowlist - anti-exfil - ingress - soc2 publishedAt: 2026-07-12 description: | # github / fence-scopes-org-allowlist **Direction:** ingress (`tool_pre_invoke`) **Default:** deny when `owner` is present and off-list; allow otherwise **Package:** `github.ingress.fence_scopes_org_allowlist` ## What it does Denies any GitHub tool call whose `arguments.owner` (read from `input.payload.args.owner`) is not one of the logins in a tenant-configured **company-org allowlist**. The allowlist is a Rego array constant (`company_orgs`) that each tenant pins to its own org login(s) at import time. Because nearly every GitHub tool carries an `owner` + `repo` pair, this one predicate closes two exfiltration paths at once — but only for tools where the `owner` argument names the repository actually being read from or written to: - **Read exfiltration** — pulling a third-party or personal private repository into agent context via owner-bearing reads such as `get_file_contents`, `get_repository_tree`, and `pull_request_read` (`method: get_diff` / `get_files`). It does **not** cover `search_code`, which takes only a `q` query string and no `owner`: a `repo:owner/name`-scoped query reaches a third-party repo unfenced (see Known limitations). - **Write exfiltration** — pushing the user's token-authorized content into an attacker- or personally-owned repository via `push_files` and `create_or_update_file`, whose `owner` is the write destination. It does **not** cover `fork_repository`: there the `owner` argument is the fork *source*, so forking a sanctioned company repo into a personal namespace (the actual IP-exfil direction) is allowed by this policy — see the public-exposure companion and Known limitations. Calls with **no resolvable `owner` argument** (e.g. `get_me`, or `search_code` invoked with only a `q` string) are allowed, because those are already scoped by the OAuth grant — there is no owner to fence against. Any call where `owner` **is** present and off-list is denied. Beware that "ownerless" also covers a few *writes* — `create_gist`/`update_gist` and `create_repository` (destination keyed on `organization`, not `owner`) — which therefore pass this fence; the public-exposure companion is what covers them (see Known limitations). The deny reason names the offending owner and points the agent back to a sanctioned company org. The check runs at ingress, before the call reaches the GitHub MCP server, so a blocked read never enters agent context and a blocked write never executes. ## Compliance alignment - **SOC 2 C1.1** — supports the requirement to identify and protect confidential information by keeping source-code IP inside the sanctioned org boundary on the agent channel. **P4.1** — supports limiting use of information to identified, sanctioned purposes (the company org). - **GDPR Art. 5(1)(b)** — supports purpose limitation: repository content the agent can reach is confined to the org the processing is authorized for. - **CCPA/CPRA §1798.121** — supports the right to limit sensitive information by preventing the agent channel from reading or writing outside the company's own namespace. This is a **PF-23 (fence-sensitive-scopes)** ingress policy: a per-tenant allowlist keyed to the org boundary rather than to IdP groups. ## Tool name matching This policy is **not** matched by tool name. The fence key is the presence and value of the `owner` argument, which almost every GitHub tool carries (`get_file_contents`, `push_files`, `fork_repository`, `create_pull_request`, `merge_pull_request`, `pull_request_read`, and so on — see the GitHub landscape note). Matching on `owner` rather than enumerating tool names keeps the policy robust as new tools are added and portable across the official (`github/github-mcp-server`) and archived community servers, which share the `owner` argument name even where tool names diverge. The consequence: **attach this policy to a GitHub-only pipeline.** If it is attached to a gateway fronting other MCP servers, a non-GitHub tool that happens to expose an `owner` argument would also be fenced. See Known limitations. ## Argument shape - `input.payload.args.owner` — the repository owner login. GitHub logins are case-insensitive, so the policy lower-cases and trims the value before comparing it to `company_orgs` (whose entries must be lowercase). A present-but-non-string `owner` (an unusual, malformed shape) is treated as off-list and denied — the policy fails closed rather than open. - Absent or empty-string `owner` → treated as an ownerless call and allowed. The GitHub MCP tool argument is `arguments.owner` at the MCP layer; the DTwo gateway surfaces it to the policy as `input.payload.args.owner`. ## Examples ### Allowed — owner is a sanctioned company org ```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-inc", "repo": "billing", "path": "README.md" } } } } ``` `allow = true`, no reason. ### Allowed — ownerless call (already OAuth-scoped) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-search_code", "type": "tool" }, "payload": { "name": "github-mcp-search_code", "args": { "q": "org:acme-inc AKIA" } } } } ``` `allow = true`, no reason. ### Denied — write into an off-list (personal/attacker) owner ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-push_files", "type": "tool" }, "payload": { "name": "github-mcp-push_files", "args": { "owner": "evil-corp", "repo": "loot", "branch": "main", "files": [] } } } } ``` `allow = false`, reason names `evil-corp` and points back to a sanctioned org. ## Composition This policy is single-purpose. Recommended companions (see the GitHub landscape note's candidate list): - The **role-gate** policy (read-only GitHub for non-engineers) — closes the ownerless read surface this policy cannot reach. - The **public-exposure** policy — forces `create_repository` private, denies public `create_gist`, and denies personal-namespace `fork_repository`. - An egress **secret-hygiene / IP-redaction** policy on `get_file_contents`, `search_code`, and `pull_request_read` responses. ## Known limitations - **Ownerless read/list tools still read across the OAuth grant.** Tools that take no `owner` — `search_code` with only a `q` string, `search_repositories`, `list_notifications`, `get_me` — remain readable across every repo the OAuth grant reaches, including the user's personal namespace. This policy cannot fence them. **Pair it with the role-gate and public-exposure policies** to cover that surface. - **Ownerless *writes* also escape the fence — including exfiltration paths.** "Ownerless" is not a synonym for "read-only." Some write tools carry no `owner` argument and so are allowed here even though they move content outward: `create_gist` / `update_gist` (content can land in a **public** gist, a direct exfil channel) and `create_repository` (the destination is the `organization` argument, not `owner`, so a new repo can be created in an off-list namespace). The gist argument schema is **unverified** in the landscape note (gists are owned by the authenticated user and expose no `owner`), so treat that as an assumption to confirm against your live `tools/list`. This fence does **not** stop these; the **public-exposure companion** (forces `create_repository` private, denies public `create_gist`) is what covers them. Note that a follow-up `push_files` into an off-list repo *is* caught here, because `push_files` carries `owner` — so the residual is the ownerless write tools themselves, not token-powered pushes. - **Attach to a GitHub-only pipeline.** The fence keys on the `owner` argument, not on the tool name. On a mixed gateway, any other server's tool that exposes an `owner` argument would also be fenced. If you must share the pipeline, add a tool-name guard. - **`q`-embedded owners are not parsed.** A `search_code` query like `q: "repo:torvalds/linux ..."` reaches a third-party repo through the query string, not the `owner` argument, so this policy does not catch it. Fence query strings with a separate rule if that path matters in your environment. - **`fork_repository` fences the source, not the destination.** Its `owner` argument is the repo being forked *from*. Forking a sanctioned company repo into a personal namespace (`organization` unset or set to a personal login) is the real IP-exfil direction and is **allowed** by this policy, because the source owner is on-list. Close that path with the public-exposure companion, which denies personal-namespace forks; do not rely on this fence for it. - **Allowlist is pinned per tenant.** `company_orgs` ships with placeholder logins (`acme-inc`, `acme-labs`). Replace them with your real org login(s) at import time; a stale or empty allowlist denies every owner-bearing call. - **Argument casing is version-dependent for other fields** (e.g. the PR-number parameter), but `owner` is stable across the official and archived servers per the landscape note. Verify against your live `tools/list` before relying on any other argument. > **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 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package github.ingress.fence_scopes_org_allowlist # Deny-by-default: a call is permitted only by an explicit allow rule below. default allow := false # --- Per-tenant configuration ------------------------------------------------ # Company GitHub org logins the agent channel is permitted to touch. Pin this # to YOUR org login(s) at import time. GitHub logins are case-insensitive, so # keep every entry lowercase — the policy lower-cases the incoming `owner` # before comparing. A stale or empty allowlist denies every owner-bearing call. company_orgs := { "acme-inc", "acme-labs", } # --- Owner resolution -------------------------------------------------------- # Raw owner argument; "" when the argument is absent. owner_arg := object.get(input.payload.args, "owner", "") # The call carries an owner when the argument is present and non-empty. # (A non-string owner, e.g. a number or object, is still "present" here and is # handled as off-list below — the policy fails closed on malformed shapes.) has_owner if { owner_arg != "" } # Canonical owner login for allowlist comparison: trimmed + lower-cased. # Only defined when `owner` is a string; a present-but-non-string owner leaves # this undefined, so it never matches the allowlist and is denied. canonical_owner := lower(trim_space(owner_arg)) if { is_string(owner_arg) } # True only when the resolved owner is a sanctioned company org. owner_on_allowlist if { company_orgs[canonical_owner] } # --- Allow rules ------------------------------------------------------------- # Ownerless calls are already scoped by the OAuth grant (e.g. get_me, or # search_code with only a `q` string) — nothing to fence. allow if { not has_owner } # Owner is present and resolves to a sanctioned company org. allow if { has_owner owner_on_allowlist } # --- Deny reason ------------------------------------------------------------- # Names the offending owner and points the agent back to the sanctioned org. reasons contains msg if { has_owner not owner_on_allowlist msg := sprintf("GitHub access is fenced to your company org allowlist. Owner '%v' is not a sanctioned org, so this call is blocked to keep source code inside the company boundary. Re-target the call at a repository your company org owns, or ask your InfoSec team to add '%v' to the allowlist if it is legitimately in scope.", [owner_arg, owner_arg]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```