# DTwo Policy Catalog — full text Every policy in the catalog, inline. Source of truth: https://github.com/dtwoai/policy-store. Browse: https://www.intentbasedpolicy.com/. Structured index: https://www.intentbasedpolicy.com/catalog.json. --- ## Stories ### Stop AI agents from leaking secrets into Slack URL: https://www.intentbasedpolicy.com/stories/stop-secrets-leaking-into-slack For: Platform and InfoSec teams running an AI assistant with Slack write access Once an agent posts to Slack, an API key is in channel history and search. Catch it before the send, not after. A Slack message is a write you can't take back. The moment your agent calls `slack-post-message`, the text is in channel history and search indexes, and may have already gone out in email digests. So when an agent drops an `AKIA…` key or a `-----BEGIN PRIVATE KEY-----` block into a "summary," redacting on read is already too late. The secret is out. The fix is to inspect the call before it reaches Slack. The `block-secrets` policy runs at ingress (`tool_pre_invoke`) and denies any send whose body matches a high-confidence secret shape: AWS keys, GitHub PATs, Slack tokens, Stripe `sk_live_` keys, OpenAI keys, PEM private keys, and generic `key: value` pairs. Other sends are unaffected. A hard deny suits high-assurance environments. Where it gets in the way of normal chatter, reach for `redact-sensitive-info` instead. It rewrites the matching substrings to `[REDACTED]` and lets the message through, minus the secret. Use deny where you can't tolerate a leak; use redact where you'd rather not break the message. Both run on the same pipeline, so you can attach either one or layer them. Policies: https://www.intentbasedpolicy.com/policies/slack/block-secrets, https://www.intentbasedpolicy.com/policies/slack/redact-sensitive-info ### Keep customer PII from walking out of your CRM URL: https://www.intentbasedpolicy.com/stories/keep-customer-pii-inside-your-crm For: RevOps and data-governance owners exposing a CRM to AI tooling An agent reading Salesforce or HubSpot pulls emails, phone numbers, and addresses into its context and your chat logs. Mask them on the way out. A CRM record carries the personal data you're accountable for: emails, mobile numbers, mailing addresses, birthdates, sometimes an SSN or a card number dropped into a notes field. Point an agent at those records to "draft a follow-up" and it pulls all of that straight into its response. From there it copies into the model context and the chat transcript, which then gets logged somewhere outside the CRM. This is an egress problem. The values already exist in Salesforce and HubSpot, so there's nothing to block on the way in. The leak happens on the way out, when the response is handed back to the MCP client. `redact-pii` (one policy per app) is transform-only. It never denies a call; it rewrites matching fields and patterns to `[REDACTED]` before the caller sees them. `Name` and `Account` stay intact by design, so the records remain usable. That covers reads. Add `protect-contact-fields` on the ingress side to stop agents from overwriting protected contact data: ownership, account linkage, consent flags. That covers both directions. Protected fields don't read out, and protected fields can't be overwritten. Policies: https://www.intentbasedpolicy.com/policies/salesforce/redact-pii, https://www.intentbasedpolicy.com/policies/hubspot/redact-pii, https://www.intentbasedpolicy.com/policies/salesforce/protect-contact-fields ### Give AI agents read-only access to your CRM URL: https://www.intentbasedpolicy.com/stories/read-only-crm-for-ai-agents For: Teams piloting AI on a CRM who want zero write risk For a low-risk CRM pilot, give the agent a one-way mirror: it reads every record and changes none. The read-only policies enforce that, and a query allowlist tightens it further. For a low-risk CRM pilot, give the agent read access only. It can query every record but cannot change any. It can't flip a deal stage, overwrite an owner, or run a bulk update from a misread instruction. `read-only` does this fail-closed. The Salesforce variant allowlists the read tools and denies the rest, so a write tool you haven't seen yet is denied by default rather than allowed. The HubSpot variant blocks the single `*-manage-crm-objects` write tool that fronts every mutation. Either way, the integration can't make a write. If even the reads need a fence, add `query-allowlist`. It restricts Salesforce SOQL to the Account, Contact, and Opportunity objects, so the agent can't query arbitrary objects across the org. Run fully read-only for the pilot. Once the workflow earns trust, relax to the narrow write-protection policies. Policies: https://www.intentbasedpolicy.com/policies/salesforce/read-only, https://www.intentbasedpolicy.com/policies/hubspot/read-only, https://www.intentbasedpolicy.com/policies/salesforce/query-allowlist ### Guard the CRM writes your revenue depends on URL: https://www.intentbasedpolicy.com/stories/guard-high-stakes-crm-writes For: RevOps teams who want productive agents without high-impact mistakes You want agents logging activities and creating records, just not closing deals or reassigning owners on their own. Gate the few writes that matter. Full read-only is too restrictive when you actually want the agent to update notes, log activities, and create records. The writes worth gating are the few that move money or change how your data is linked. This set is four single-purpose deny policies. Each one is `default allow := false` re-allowing everything except its own narrow concern, so they don't step on each other or on the rest of your CRM policies. `block-deal-closure` denies moves into `closedwon` or `closedlost`. The agent can advance a deal; it just can't declare it won or lost. `protect-deal-owner` denies changes to `hubspot_owner_id`, which keeps attribution and territory under human control. Object associations are handled by `protect-associations`, which denies creating or rewiring them. And `protect-lifecycle-stage` denies changes to a contact's `lifecyclestage`, so funnel reporting stays honest. Attach the ones that map to your guardrails. They're independent, so you can attach just `block-deal-closure` now and add the rest later. Policies: https://www.intentbasedpolicy.com/policies/hubspot/block-deal-closure, https://www.intentbasedpolicy.com/policies/hubspot/protect-deal-owner, https://www.intentbasedpolicy.com/policies/hubspot/protect-associations, https://www.intentbasedpolicy.com/policies/hubspot/protect-lifecycle-stage ### Wall off sensitive Jira projects from AI agents URL: https://www.intentbasedpolicy.com/stories/wall-off-sensitive-jira-projects For: Teams running AI on Jira with confidential projects in the same instance Security, legal, and HR projects share the same Jira as your sprint board. Keep agents from reading or writing them, and redact whatever still comes back. Your security, legal, and HR projects sit in the same Jira instance as everything else. Incident-response, legal-hold, and HR-investigation work lives next to the sprint board, and an agent with Jira access sees all of it. Ask it to "summarize open issues" and the active security incident is in scope too. The control is project-scoped and covers both ways into an issue. `deny-view-search-sensitive-projects` blocks the read side: it denies the two paths a caller has to reach an issue — fetching it directly by key, and JQL search — matched by project key, so the content never reaches the model context. The write side is handled by `deny-write-sensitive-projects`, which stops an agent from creating, commenting on, or transitioning issues in those projects. Whatever still comes back — including a summary the agent built from issue content it was allowed to read — runs through `redact-sensitive-info`, which masks secrets and PII before the response reaches the caller. The read and write policies each carry their own sensitive-project list, so set the same projects in both to keep the fences aligned. The `atlassian` bundle collects the curated set. Policies: https://www.intentbasedpolicy.com/policies/jira/deny-view-search-sensitive-projects, https://www.intentbasedpolicy.com/policies/jira/deny-write-sensitive-projects, https://www.intentbasedpolicy.com/policies/jira/redact-sensitive-info ### Slack hygiene for autonomous AI agents URL: https://www.intentbasedpolicy.com/stories/slack-hygiene-for-ai-agents For: Platform teams granting agents Slack access beyond a single channel Slack's OAuth scopes pick capabilities, not the channels they apply to: you can grant an agent 'post messages,' but not 'post only in #status' — one scope covers every channel at once. Slack OAuth scopes let you pick capabilities, but not the channels or recipients they apply to. `chat:write` lets an agent post in every channel it's in, not just the one you intended; `channels:history` reads every public channel, not a chosen few; and there's no scope for 'DM teammates but never outsiders.' Apps also tend to request a bundle of scopes to cover many features, so an agent inherits all of it. The gateway is where you narrow that to specific channels and actions, since the scopes can't. Each of these three ingress policies blocks one specific action and leaves the rest of the Slack tools available. `deny-channel-creation` stops channel sprawl from an over-eager agent. `deny-direct-messages` denies writes addressed to a 1:1 DM, a user ID, or a group DM, so the agent stays in channels instead of private conversations. `deny-read-search-summarize-sensitive-channels` keeps named sensitive channels out of reach for read, search, and summarize. Add `block-secrets` from the secrets story and the agent is limited to posting in approved channels with no secrets in the payload. The `slack` bundle gathers the hygiene set. Policies: https://www.intentbasedpolicy.com/policies/slack/deny-channel-creation, https://www.intentbasedpolicy.com/policies/slack/deny-direct-messages, https://www.intentbasedpolicy.com/policies/slack/deny-read-search-summarize-sensitive-channels --- ## Policies ### Block Secrets in Slack Messages URL: https://www.intentbasedpolicy.com/policies/slack/block-secrets App(s): slack | Direction: ingress | Bundles: im-messaging | Package: slack.ingress.block_secrets | Published: 2026-06-02 | Tags: slack, secrets, dlp, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/block-secrets/policy.md # slack / block-secrets **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `slack.ingress.block_secrets` ## What it does Blocks Slack send-message tool calls whose message body looks like it contains a secret — API keys, passwords, tokens, or PEM-formatted private keys. All other tool calls pass through unchanged. The check runs at ingress, before the call reaches the Slack MCP server, so a blocked message is never delivered to Slack and never appears in any channel's history. ## Why ingress and not egress Sending a Slack message is a write with permanent side effects — once the call reaches Slack the message exists in channel history and may already be syndicated to email digests, search indexes, or DMs to other workspace members. Egress redaction would only mask the response to the caller, not the message itself. Ingress denial is the only way to actually prevent the leak. ## Patterns matched The policy uses a small set of conservative regex patterns. Adding too many patterns dramatically increases false positives, so the list is intentionally focused on high-confidence shapes: - `password:`, `token:`, `api_key:`, `client_secret:`, etc. in `key: value` or `key=value` form (case-insensitive) - AWS access key IDs (`AKIA…`) and likely secret access keys - GitHub personal access tokens (`ghp_…`, `github_pat_…`) - Slack bot/user/admin tokens (`xoxb-`, `xoxp-`, `xoxa-`, `xoxr-`) - Stripe live secret keys (`sk_live_…`) - Google API keys (`AIza…`) - OpenAI API keys (`sk-…`) - PEM private key headers (`-----BEGIN … PRIVATE KEY-----`) Tune this list for your environment. If your team uses other providers (Twilio, SendGrid, Datadog, etc.), add their token shapes to `secret_patterns` in `policy.md`. ## Tool name matching The policy matches the Slack send-message tool by suffix: - `*slack-post-message` - `*slack-send-message` - `*postmessage` The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `slack-mcp-slack-post-message`), and that prefix is not standardized — different deployments use different server names. Matching on the suffix keeps the policy portable, but you should verify the exact name your gateway sends using the [dump-input debug technique](https://docs.dtwo.ai) before relying on this in production. If the Slack MCP server you use exposes a different tool name for send/post, add it to `is_slack_send_tool` in `policy.md`. ## Argument shape The policy reads the message body from two common argument keys, in order: 1. `input.payload.args.text` (used by the official Anthropic Slack MCP server and most community implementations) 2. `input.payload.args.message` (used by a few alternatives) If your MCP server exposes the body under a different key, add another `message_text` rule. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack-post-message", "type": "tool" }, "payload": { "name": "slack-mcp-slack-post-message", "args": { "channel": "C123", "text": "lunch in 5" } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack-post-message", "type": "tool" }, "payload": { "name": "slack-mcp-slack-post-message", "args": { "channel": "C123", "text": "here's the api_key: sk-abcdef0123456789abcdef0123456789" } } } } ``` `allow = false`, `reason = "This Slack message looks like it contains a secret (...)"`. ## Composition This policy is single-purpose. Useful companions: - A separate ingress policy that **redacts** rather than blocks (for environments where rejecting the call is too disruptive — replace this policy with a transform-only version that rewrites `text`). - An egress PII redaction policy on Slack search/history tools so previously-posted secrets are masked when read back. See the [`bundles/im-messaging`](../../../bundles/im-messaging/README.md) bundle for the curated set. ## Known limitations - **Regex over plain text.** Secrets concatenated into longer sentences may still match; secrets that don't match a known shape (rotating short-lived tokens, custom-format keys) will not. Treat this as a high-signal first line of defense, not a complete DLP solution. - **Attachments and blocks not inspected.** Slack send-message tools accept `attachments` and `blocks` arguments containing structured content. This policy only inspects the top-level `text` / `message` string. Extend `message_text` rules if your environment routinely sends secret-laden content through those fields. - **No identity-based exemptions.** All callers are subject to the same check. If you need an InfoSec break-glass user that can post anything, gate it with `input.subject.claims` as a separate `allow if` branch. ```rego package slack.ingress.block_secrets # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Patterns that look like secrets in plain text. Anchored to common shapes # (key=value pairs and provider-specific prefixes) to limit false positives. secret_patterns := [ # Generic password / token / api_key / secret_key in `key: value` or `key=value` form `(?i)(?:password|passwd|secret|token|api[_-]?key|secret[_-]?key|access[_-]?key|client[_-]?secret)\s*[:=]\s*\S+`, # AWS access key IDs `AKIA[0-9A-Z]{16}`, # AWS secret access keys (40-char base64-ish) `(?i)aws(.{0,20})?(secret|access)?.{0,20}[\s:=]+[A-Za-z0-9/+=]{40}`, # GitHub fine-grained / classic personal access tokens `ghp_[A-Za-z0-9]{36}`, `github_pat_[A-Za-z0-9_]{82}`, # Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-) `xox[baprs]-[A-Za-z0-9-]{10,}`, # Stripe live secret keys `sk_live_[A-Za-z0-9]{24,}`, # Google API keys `AIza[0-9A-Za-z\-_]{35}`, # OpenAI API keys `sk-[A-Za-z0-9]{20,}`, # Generic private key headers `-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`, ] # Slack send-message tools we want to inspect. The gateway prefixes tool # names with the configured MCP server name (e.g. `slack-mcp-`), so we match # on the suffix to stay portable across naming conventions. Verify the exact # tool name on your gateway with the dump-input debug technique before relying # on this in production. is_slack_send_tool if { name := lower(input.resource.name) endswith(name, "slack-post-message") } is_slack_send_tool if { name := lower(input.resource.name) endswith(name, "slack-send-message") } is_slack_send_tool if { name := lower(input.resource.name) endswith(name, "postmessage") } # Allow any tool that isn't a Slack send-message call. allow if { not is_slack_send_tool } # Allow Slack send-message calls only when no secret pattern matches the body. allow if { is_slack_send_tool not message_contains_secret } # Pull the message body from the common argument names Slack MCP servers use. message_text := text if { text := object.get(input.payload.args, "text", "") text != "" } message_text := text if { object.get(input.payload.args, "text", "") == "" text := object.get(input.payload.args, "message", "") text != "" } # Detect a secret pattern in the message body. message_contains_secret if { some pattern in secret_patterns regex.match(pattern, message_text) } reasons contains "This Slack message looks like it contains a secret (API key, password, token, or private key). Send credentials through your secret manager instead. Contact your InfoSec team if this was a false positive." if { is_slack_send_tool message_contains_secret } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### HubSpot Block Deal Closure URL: https://www.intentbasedpolicy.com/policies/hubspot/block-deal-closure App(s): hubspot | Direction: ingress | Bundles: crm | Package: hubspot.ingress.no_close_deal | Published: 2026-06-16 | Tags: hubspot, deals, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/block-deal-closure/policy.md # hubspot / block-deal-closure **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.no_close_deal` ## What it does Blocks HubSpot CRM-object calls that move a deal into a closed stage (`closedwon` or `closedlost`). Both create and update requests are inspected. Every other deal change — and every other HubSpot tool — passes through unchanged. ## Why ingress Closing a deal is a write with permanent side effects on the CRM (revenue reporting, workflow automation, downstream syncs). The violation is fully determined by the request payload, so denying at ingress prevents the stage change from ever reaching HubSpot. ## How it matches Two conditions must both hold for a call to be denied: - **Tool match.** The (lowercased) tool name ends with `-manage-crm-objects` — the HubSpot MCP tool that creates and updates CRM records. Suffix matching keeps the policy portable regardless of the MCP server name the gateway adds as a prefix. - **Closing a deal.** Within the call's `createRequest.objects` or `updateRequest.objects`, an object whose `objectType` is `deals` sets `properties.dealstage` to `closedwon` or `closedlost` (case-insensitive). ## Tool naming on the gateway DTwo prefixes tool names with the MCP server name configured on the gateway, so a HubSpot server registered as `hubspot` surfaces `hubspot-manage-crm-objects` while one registered as `hubspot-mcp` surfaces `hubspot-mcp-manage-crm-objects`. This policy matches on the **suffix** (`-manage-crm-objects`) so it stays portable across naming conventions. Confirm the exact tool name with the dump-input debug technique before deploying. ## Configuring closed stages The blocked stages live in `closed_stages` at the top of the Rego (`closedwon`, `closedlost`). If your pipeline uses custom closed-stage internal names, add them there. ## Examples ### Allowed (non-closing update) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "deals", "id": "12345", "properties": { "dealstage": "qualifiedtobuy" } } ] } } } } } ``` `allow = true`, no reason. ### Denied (closing a deal) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "deals", "id": "12345", "properties": { "dealstage": "closedwon" } } ] } } } } } ``` `allow = false`, `reason = "Closing deals is not allowed. Contact your InfoSec team to get access."`. ## Known limitations - **Suffix tool-name match.** The policy matches any tool ending in `-manage-crm-objects`. If a non-HubSpot MCP server happened to expose a tool with that same suffix, it would also be inspected — narrow the match if that is a concern in your environment. - **Custom stage names.** Only `closedwon` / `closedlost` are blocked by default; custom closed-stage internal names must be added to `closed_stages`. - **No identity-based exemptions.** All callers are treated the same. To allow a break-glass role to close deals, add an `allow if` branch gated on `input.subject.claims`. ```rego package hubspot.ingress.no_close_deal default allow := false closed_stages := {"closedwon", "closedlost"} allow if { not is_closing_deal } is_closing_deal if { endswith(lower(input.resource.name), "-manage-crm-objects") some obj in object.get(object.get(input.payload.args, "updateRequest", {}), "objects", []) lower(object.get(obj, "objectType", "")) == "deals" stage := lower(object.get(object.get(obj, "properties", {}), "dealstage", "")) closed_stages[stage] } is_closing_deal if { endswith(lower(input.resource.name), "-manage-crm-objects") some obj in object.get(object.get(input.payload.args, "createRequest", {}), "objects", []) lower(object.get(obj, "objectType", "")) == "deals" stage := lower(object.get(object.get(obj, "properties", {}), "dealstage", "")) closed_stages[stage] } reasons contains "Closing deals is not allowed. Contact your InfoSec team to get access." if { is_closing_deal } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### HubSpot Protect Associations URL: https://www.intentbasedpolicy.com/policies/hubspot/protect-associations App(s): hubspot | Direction: ingress | Bundles: crm | Package: hubspot.ingress.protect_associations | Published: 2026-06-16 | Tags: hubspot, associations, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/protect-associations/policy.md # hubspot / protect-associations **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.protect_associations` ## What it does Blocks HubSpot CRM-object calls that create or change associations between objects (deal↔company, contact↔company, etc.). Any `hubspot-manage-crm-objects` call carrying a non-empty `associations` array on one or more objects — in either `createRequest` or `updateRequest` — is denied. Every other call, including object create/update with no association payload, passes through unchanged. ## Why ingress Associations are structural CRM relationships with downstream effects (reporting rollups, workflow enrollment, record visibility). The violation is fully determined by the request payload, so denying at ingress prevents the association change from ever reaching HubSpot. ## How it matches Two conditions must both hold for a call to be denied: - **Tool match.** The (lowercased) tool name ends with `-manage-crm-objects` — the HubSpot MCP tool that creates and updates CRM records. Suffix matching keeps the policy portable regardless of the MCP server name the gateway adds as a prefix. - **Association payload present.** Within the call's `createRequest.objects` or `updateRequest.objects`, at least one object has a non-empty `associations` array (`count(...) > 0`). ## Tool naming on the gateway DTwo prefixes tool names with the MCP server name configured on the gateway, so a HubSpot server registered as `hubspot` surfaces `hubspot-manage-crm-objects` while one registered as `hubspot-mcp` surfaces `hubspot-mcp-manage-crm-objects`. This policy matches on the **suffix** (`-manage-crm-objects`) so it stays portable across naming conventions. Confirm the exact tool name with the dump-input debug technique before deploying. ## Examples ### Allowed (object update with no associations) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "deals", "id": "12345", "properties": { "amount": "500" } } ] } } } } } ``` `allow = true`, no reason. ### Denied (association change) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "deals", "id": "12345", "associations": [ { "to": { "id": "67890" }, "types": [ { "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 5 } ] } ] } ] } } } } } ``` `allow = false`, `reason = "Modifying associations is not permitted through this gateway. Contact your admin to manage object associations."`. ## Known limitations - **Suffix tool-name match.** The policy matches any tool ending in `-manage-crm-objects`. If a non-HubSpot MCP server happened to expose a tool with that same suffix, it would also be inspected — narrow the match if that is a concern in your environment. - **Presence-based, not value-aware.** The policy denies whenever a non-empty `associations` array is present; it does not distinguish which objects are being linked. Narrow the rule if you need to allow specific association types. - **No identity-based exemptions.** All callers are treated the same. To allow a break-glass role to manage associations, add an `allow if` branch gated on `input.subject.claims`. ```rego package hubspot.ingress.protect_associations default allow := false allow if { not is_association_change } is_association_change if { endswith(lower(input.resource.name), "-manage-crm-objects") some obj in object.get(object.get(input.payload.args, "updateRequest", {}), "objects", []) count(object.get(obj, "associations", [])) > 0 } is_association_change if { endswith(lower(input.resource.name), "-manage-crm-objects") some obj in object.get(object.get(input.payload.args, "createRequest", {}), "objects", []) count(object.get(obj, "associations", [])) > 0 } reason := "Modifying associations is not permitted through this gateway. Contact your admin to manage object associations." if not allow ``` ### HubSpot Protect Deal Owner URL: https://www.intentbasedpolicy.com/policies/hubspot/protect-deal-owner App(s): hubspot | Direction: ingress | Bundles: crm | Package: hubspot.ingress.protect_deal_owner | Published: 2026-06-16 | Tags: hubspot, deals, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/protect-deal-owner/policy.md # hubspot / protect-deal-owner **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.protect_deal_owner` ## What it does Blocks HubSpot CRM-object update calls that set or change a deal's owner. Any `hubspot-manage-crm-objects` update whose deal `properties` include the `hubspot_owner_id` key is denied — this covers both initial owner assignment and reassignment. Deal creates, other update fields, and all other tools pass through unchanged. ## Why ingress Deal ownership drives quota attribution, territory routing, and reporting. The violation is fully determined by the request payload, so denying at ingress prevents the ownership change from ever reaching HubSpot. ## How it matches All of the following must hold for a call to be denied: - **Tool match.** The (lowercased) tool name ends with `-manage-crm-objects` (suffix matching keeps the policy portable regardless of the MCP server name prefix the gateway adds). - **Deal update.** An object in `updateRequest.objects` has `objectType` `deals` (case-insensitive). - **Owner field present.** That object's `properties` include the `hubspot_owner_id` key (presence alone triggers the deny — the value is not inspected). Only `updateRequest` is inspected; deal creates are intentionally not blocked. ## Tool naming on the gateway DTwo prefixes tool names with the MCP server name configured on the gateway, so a HubSpot server registered as `hubspot` surfaces `hubspot-manage-crm-objects` while one registered as `hubspot-mcp` surfaces `hubspot-mcp-manage-crm-objects`. This policy matches on the **suffix** (`-manage-crm-objects`) so it stays portable across naming conventions. Confirm the exact tool name with the dump-input debug technique before deploying. ## Examples ### Allowed (deal update with no owner change) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "deals", "id": "12345", "properties": { "amount": "500" } } ] } } } } } ``` `allow = true`, no reason. ### Denied (owner reassignment) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "deals", "id": "12345", "properties": { "hubspot_owner_id": "99887766" } } ] } } } } } ``` `allow = false`, `reason = "Changing the owner of a deal is not permitted through this gateway. Contact your admin to reassign deals."`. ## Known limitations - **Suffix tool-name match.** The policy matches any tool ending in `-manage-crm-objects`. If a non-HubSpot MCP server happened to expose a tool with that same suffix, it would also be inspected — narrow the match if that is a concern in your environment. - **Updates only.** Deal creates that set `hubspot_owner_id` are not blocked by design. Add a `createRequest` branch if you also want to fix owner at creation. - **No identity-based exemptions.** All callers are treated the same. To allow a break-glass role to reassign deals, add an `allow if` branch gated on `input.subject.claims`. ```rego package hubspot.ingress.protect_deal_owner default allow := false allow if { not is_owner_update } is_owner_update if { endswith(lower(input.resource.name), "-manage-crm-objects") some obj in object.get(object.get(input.payload.args, "updateRequest", {}), "objects", []) lower(object.get(obj, "objectType", "")) == "deals" "hubspot_owner_id" in object.keys(object.get(obj, "properties", {})) } reasons contains "Changing the owner of a deal is not permitted through this gateway. Contact your admin to reassign deals." if { is_owner_update } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### HubSpot Protect Lifecycle Stage URL: https://www.intentbasedpolicy.com/policies/hubspot/protect-lifecycle-stage App(s): hubspot | Direction: ingress | Bundles: crm | Package: hubspot.ingress.protect_lifecycle_stage | Published: 2026-06-16 | Tags: hubspot, contacts, lifecycle, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/protect-lifecycle-stage/policy.md # hubspot / protect-lifecycle-stage **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.protect_lifecycle_stage` ## What it does Blocks HubSpot CRM-object calls that set or change a contact's lifecycle stage. Any `hubspot-manage-crm-objects` call where a `contacts` object's `properties` include the `lifecyclestage` key — in either `createRequest` or `updateRequest` — is denied. Non-contact objects, contact edits that don't touch `lifecyclestage`, and all other tools pass through unchanged. ## Why ingress Lifecycle stage drives marketing automation, lead routing, and funnel reporting. The violation is fully determined by the request payload, so denying at ingress prevents the stage change from ever reaching HubSpot. ## How it matches All of the following must hold for a call to be denied: - **Tool match.** The (lowercased) tool name ends with `-manage-crm-objects` (suffix matching keeps the policy portable regardless of the MCP server name prefix the gateway adds). - **Contact object.** An object in `createRequest.objects` or `updateRequest.objects` has `objectType` `contacts` (case-insensitive). - **Lifecycle field present.** That object's `properties` include the `lifecyclestage` key (presence alone triggers the deny — the value is not inspected). ## Tool naming on the gateway DTwo prefixes tool names with the MCP server name configured on the gateway, so a HubSpot server registered as `hubspot` surfaces `hubspot-manage-crm-objects` while one registered as `hubspot-mcp` surfaces `hubspot-mcp-manage-crm-objects`. This policy matches on the **suffix** (`-manage-crm-objects`) so it stays portable across naming conventions. Confirm the exact tool name with the dump-input debug technique before deploying. ## Examples ### Allowed (contact update with no lifecycle change) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "contacts", "id": "12345", "properties": { "email": "lead@example.com" } } ] } } } } } ``` `allow = true`, no reason. ### Denied (lifecycle stage change) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "contacts", "id": "12345", "properties": { "lifecyclestage": "customer" } } ] } } } } } ``` `allow = false`, `reason = "Changing the lifecycle stage of a contact is not permitted through this gateway. Contact your admin to update lifecycle stages."`. ## Known limitations - **Suffix tool-name match.** The policy matches any tool ending in `-manage-crm-objects`. If a non-HubSpot MCP server happened to expose a tool with that same suffix, it would also be inspected — narrow the match if that is a concern in your environment. - **Contacts only.** Only `contacts` objects are inspected; `lifecyclestage` on other object types is not blocked. Extend `is_lifecycle_change` if your environment uses lifecycle stage on companies or custom objects. - **No identity-based exemptions.** All callers are treated the same. To allow a break-glass role to change lifecycle stages, add an `allow if` branch gated on `input.subject.claims`. ```rego package hubspot.ingress.protect_lifecycle_stage default allow := false allow if { not is_lifecycle_change } is_lifecycle_change if { endswith(lower(input.resource.name), "-manage-crm-objects") some obj in object.get(object.get(input.payload.args, "updateRequest", {}), "objects", []) lower(object.get(obj, "objectType", "")) == "contacts" "lifecyclestage" in object.keys(object.get(obj, "properties", {})) } is_lifecycle_change if { endswith(lower(input.resource.name), "-manage-crm-objects") some obj in object.get(object.get(input.payload.args, "createRequest", {}), "objects", []) lower(object.get(obj, "objectType", "")) == "contacts" "lifecyclestage" in object.keys(object.get(obj, "properties", {})) } reason := "Changing the lifecycle stage of a contact is not permitted through this gateway. Contact your admin to update lifecycle stages." if not allow ``` ### HubSpot Read-Only URL: https://www.intentbasedpolicy.com/policies/hubspot/read-only App(s): hubspot | Direction: ingress | Bundles: crm | Package: hubspot.ingress.readonly | Published: 2026-06-16 | Tags: hubspot, access-control, governance, read-only, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/read-only/policy.md # hubspot / read-only **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.readonly` ## What it does Makes the HubSpot connection read-only by blocking the write tool. Any call to `hubspot-manage-crm-objects` — the create/update tool exposed by the HubSpot MCP server — is denied. Every other HubSpot tool (search, list, read) passes through unchanged. ## Why ingress Writes have permanent side effects on the CRM. The connection's read/write posture is fully determined by the tool being called, so denying the write tool at ingress guarantees no mutation reaches HubSpot regardless of the payload. ## How it matches The policy is `default allow := false` and re-allows every tool **except** the one whose (lowercased) name ends with `-manage-crm-objects`. Suffix matching keeps the policy portable regardless of the MCP server name prefix the gateway adds (`hubspot-`, `hubspot-mcp-`, etc.). Confirm the exact tool name with the dump-input debug technique before deploying. ## Examples ### Allowed (read tool) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-list-objects", "type": "tool" }, "payload": { "name": "hubspot-list-objects", "args": { "objectType": "deals" } } } } ``` `allow = true`, no reason. ### Denied (write tool) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-manage-crm-objects", "type": "tool" }, "payload": { "name": "hubspot-manage-crm-objects", "args": { "updateRequest": { "objects": [ { "objectType": "deals", "id": "12345" } ] } } } } } ``` `allow = false`, `reason = "HubSpot write operations are disabled on this gateway. This connection is read-only."`. ## Known limitations - **Single write tool.** This assumes `hubspot-manage-crm-objects` is the only write tool exposed by the HubSpot MCP server on the gateway. If your server exposes other mutating tools (e.g. dedicated association or engagement endpoints), add their suffixes to the deny condition. - **Suffix tool-name match.** The policy allows any tool that does *not* end in `-manage-crm-objects`. If a non-HubSpot MCP server exposed a tool with that same suffix, it would also be blocked — narrow the match if that is a concern. - **No identity-based exemptions.** All callers are read-only. To allow a break-glass writer, add an `allow if` branch gated on `input.subject.claims`. ```rego package hubspot.ingress.readonly default allow := false allow if { not endswith(lower(input.resource.name), "-manage-crm-objects") } reason := "HubSpot write operations are disabled on this gateway. This connection is read-only." if not allow ``` ### HubSpot Redact PII URL: https://www.intentbasedpolicy.com/policies/hubspot/redact-pii App(s): hubspot | Direction: egress | Bundles: crm | Package: hubspot.egress.redact_pii | Published: 2026-06-16 | Tags: hubspot, pii, dlp, redaction, egress Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/redact-pii/policy.md # hubspot / redact-pii **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `hubspot.egress.redact_pii` ## What it does Redacts sensitive contact information from HubSpot tool responses before they reach the caller. It is transform-only — it never denies a call, it only rewrites matching content to `[REDACTED]`. Any non-HubSpot tool, and any request that isn't on the output path, passes through untouched. ## Why egress The risk is *reading* PII that lives in HubSpot CRM records (phone numbers, emails, etc.). Those values exist regardless of this gateway, so there is nothing to block at ingress — the leak happens when the content is returned to an MCP client. Masking on the egress (response) path is the only place to catch it. ## Scope / tool matching Applies to any tool whose (lowercased) name starts with `hubspot-`, on the output path (`input.mode == "output"`). Confirm the exact tool names and the server-name prefix your gateway emits with the dump-input debug technique before relying on this in production; if your HubSpot MCP server is registered under a different prefix, adjust the `startswith` check. ## What gets redacted Redaction works two ways. **By field name** — the structured fields `phone`, `mobilephone`, `fax`, `email`, and `hs_email_domain`. And **by pattern** in any string value: - Formatted phone numbers (with separators, e.g. `555-666-7777`, `(555) 666.7777`, `+1-555-666-7777`) - Raw 10-digit phone numbers (`\b\d{10}\b`, bounded so it won't match inside longer HubSpot IDs) - Email addresses - US SSNs (`XXX-XX-XXXX`) Matches are replaced with `[REDACTED]`. ## Examples ### Redacted (HubSpot tool response) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "hubspot-list-objects", "type": "tool" } } } ``` `allow = true`, with a `transform` supplying the redaction patterns, field names, and `replacement = "[REDACTED]"` for the gateway to apply to the response body, plus `reason = "PII redacted from HubSpot response"` so the redaction is explained in the dashboard. ### Passed through (non-HubSpot tool, or not output path) A response from a non-`hubspot-` tool, or any request not on the output path, returns `allow = true` with no `transform` — unchanged. ## Known limitations - **Regex over text.** Detection is pattern-based, so novel formats and non-standard shapes may be missed, and benign strings that look like a phone or email may be over-redacted. Treat this as a high-signal layer, not a complete DLP solution. - **Prefix-scoped.** Scoping is `startswith("hubspot-")`. A HubSpot MCP server registered under a different prefix won't be covered until the check is adjusted. - **No identity-based exemptions.** All callers get the same redaction. Add an `input.subject.claims`-gated branch if a break-glass role needs raw values. ```rego package hubspot.egress.redact_pii # Transform-only policy — never blocks, only redacts sensitive fields from HubSpot responses default allow := true transform := { "redact_patterns": [ # Formatted phone numbers — requires at least one separator between digit groups # Matches: 555-666-7777, (555) 666.7777, +1-555-666-7777 # Does NOT match raw digit strings like 5556667777 (handled below) "\\+?1?[\\s.\\-]?\\(?\\d{3}\\)?[\\s.\\-]\\d{3}[\\s.\\-]\\d{4}", # Raw 10-digit phone numbers (exactly 10 consecutive digits) # \b ensures it won't match inside longer numbers like 12-digit HubSpot IDs "\\b\\d{10}\\b", # Email addresses "[\\w.+\\-]+@[\\w.\\-]+\\.[a-zA-Z]{2,}", # SSNs (XXX-XX-XXXX) "\\b\\d{3}-\\d{2}-\\d{4}\\b" ], "redact_fields": ["phone", "mobilephone", "fax", "email", "hs_email_domain"], "replacement": "[REDACTED]" } if { input.mode == "output" startswith(lower(input.resource.name), "hubspot-") } # Surfaced on the decision event whenever the redaction is in scope, so the # dashboard can explain the rewrite. reason := "PII redacted from HubSpot response" if { input.mode == "output" startswith(lower(input.resource.name), "hubspot-") } ``` ### JIRA: Deny Sensitive Project Search and View URL: https://www.intentbasedpolicy.com/policies/jira/deny-view-search-sensitive-projects App(s): jira | Direction: ingress | Bundles: atlassian | Package: jira.ingress.deny_sensitive_search_and_view | Published: 2026-06-16 | Tags: jira, atlassian, access-control, data-protection, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/deny-view-search-sensitive-projects/policy.md # jira / deny-view-search-sensitive-projects **Direction:** ingress (`tool_pre_invoke`) **Default:** allow, with targeted denies and a silent search filter **Package:** `jira.ingress.deny_sensitive_search_and_view` ## What it does Keeps issues that belong to a configurable set of "sensitive" JIRA projects out of read access through the JIRA MCP server. It guards the two read paths a caller can use to reach an issue — fetching an issue directly by key, and searching with JQL — and leaves every other JIRA tool and every other project untouched. The set of sensitive projects is configured once at the top of the Rego (`sensitive_projects`) and all comparisons are case-insensitive. ## Behavior The policy is `default allow := true` and enforces three behaviors against the two read tools: - **Deny direct views.** A `*-getjiraissue` call whose `issueIdOrKey` resolves to a sensitive project is denied, and the caller receives a reason naming the affected project. - **Deny explicit searches.** A `*-searchjiraissuesusingjql` call whose JQL explicitly references a sensitive project — via `project = X`, `project in (... X ...)`, or any `X-NNN` issue key — is denied with a reason naming the matched project(s). - **Silently filter generic searches.** Any other `*-searchjiraissuesusingjql` call (generic filter, `ORDER BY` only, empty JQL, etc.) is rewritten to prepend a `project NOT IN (...)` clause so sensitive-project issues never appear in the result set. The caller gets results back, just without the protected issues. ## Why ingress Both read paths can be fully evaluated from the request alone (tool name + arguments), so enforcement happens before the call reaches JIRA. Direct views and explicit searches are denied outright; generic searches are rewritten in place. Doing this at ingress means sensitive issues are never fetched from JIRA in the first place. For defense in depth, pair this with the egress redaction policy in the same bundle as a backstop for any issue reached by a path this policy doesn't cover. ## Tool name matching Tool names on the gateway are prefixed with the configured MCP server name (e.g. `atlassian-jira-mcp-getjiraissue`), and that prefix is not standardized. The policy matches on the tool-name **suffix** (`-getjiraissue`, `-searchjiraissuesusingjql`) so it stays portable across naming conventions. Confirm the exact tool names your gateway emits with the dump-input debug technique before relying on this in production. ## Argument shape - Direct view: reads the issue key from `input.payload.args.issueIdOrKey`. - Search: reads the query from `input.payload.args.jql`. If your JIRA MCP server exposes these under different argument keys, adjust the `object.get(...)` lookups accordingly. ## Configuration Edit the `sensitive_projects` set at the top of the policy. The shipped keys (`PROJA`, `PROJB`) are **placeholders** — replace them with your own project keys (uppercase). If you also run a companion writes-protection policy for the same projects, mirror this set there so view/search and write restrictions stay aligned. ## Examples ### Allowed (generic search — silently filtered) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-jira-mcp-searchjiraissuesusingjql", "type": "tool" }, "payload": { "name": "atlassian-jira-mcp-searchjiraissuesusingjql", "args": { "jql": "assignee = currentUser() ORDER BY created DESC" } } } } ``` `allow = true`. The JQL is rewritten to `project NOT IN (PROJA, PROJB) AND (assignee = currentUser()) ORDER BY created DESC`. ### Denied (explicit sensitive-project search) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-jira-mcp-searchjiraissuesusingjql", "type": "tool" }, "payload": { "name": "atlassian-jira-mcp-searchjiraissuesusingjql", "args": { "jql": "project = PROJA ORDER BY created DESC" } } } } ``` `allow = false`, `reason = "Searching for issues in protected project(s) (PROJA) is not permitted. ..."`. ### Denied (direct view of a sensitive issue) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-jira-mcp-getjiraissue", "type": "tool" }, "payload": { "name": "atlassian-jira-mcp-getjiraissue", "args": { "issueIdOrKey": "PROJA-42" } } } } ``` `allow = false`, `reason = "Viewing issues in the 'PROJA' project is not permitted. ..."`. ## Composition This policy covers the read surface. Useful companions: - The [`redact-sensitive-info`](../redact-sensitive-info/policy.md) egress policy in the same bundle, as a backstop that masks PII/secrets in any issue content that is returned. - A separate ingress write-protection policy that denies create/edit/comment/transition on the same sensitive projects. ## Known limitations - **JQL is inspected with regex, not a parser.** The explicit-reference detection covers the common `project = X`, `project in (...)`, and `X-NNN` shapes. Exotic JQL (functions, deeply nested boolean logic, fields that indirectly imply a project) may not be detected as an *explicit* reference — in that case the call falls through to the silent `project NOT IN (...)` rewrite, which still excludes sensitive projects from results. - **Read paths only.** This policy does not restrict writes. Pair it with a write-protection policy if callers can create or edit issues. - **No identity-based exemptions.** All callers are treated the same. To add an InfoSec break-glass user, gate a separate `allow if` branch on `input.subject.claims`. ```rego package jira.ingress.deny_sensitive_search_and_view # Default-allow: only deny when a sensitive-project view or explicit search is # detected. For generic searches, a transform rewrites the JQL to silently # exclude sensitive projects (see Transform section below). default allow := true # ----------------------------------------------------------------------------- # CONFIG: Sensitive project keys. Edit this set to add or remove projects. # The keys below (PROJA, PROJB) are PLACEHOLDERS — replace them with your own # project keys. Stored UPPERCASE; all comparisons normalize input to upper case. # If you also run a companion writes-protection policy, mirror this set there to # keep view/search restrictions and write restrictions aligned. # ----------------------------------------------------------------------------- sensitive_projects := { "PROJA", "PROJB", } # Suffix matching works regardless of the MCP server name on the gateway # (atlassian-, atlassian-jira-mcp-, etc.). view_tool_suffixes := {"-getjiraissue"} search_tool_suffixes := {"-searchjiraissuesusingjql"} # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- tool_name := lower(input.resource.name) is_search_tool if { some suffix in search_tool_suffixes endswith(tool_name, suffix) } # JQL clause used to exclude every configured sensitive project from a search. # Sorted for stable, deterministic output (eases debugging). exclusion_clause := sprintf( "project NOT IN (%s)", [concat(", ", sort([p | some p in sensitive_projects]))], ) # Extract the project key from a JIRA issue key like "PROJA-123" -> "PROJA". project_from_issue_key(key) := project if { parts := split(key, "-") count(parts) >= 2 project := upper(parts[0]) project != "" } # True when the request is fetching an issue in a sensitive project. view_targets_sensitive_project if { key := object.get(input.payload.args, "issueIdOrKey", "") project := project_from_issue_key(key) sensitive_projects[project] } # ----------------------------------------------------------------------------- # JQL inspection — collect the specific sensitive projects the JQL references. # Three regex shapes per project; any match adds the project to the set. # # Pattern A: project = (with optional quotes/whitespace) # Pattern B: project in (... ...) # Pattern C: -NNN (any specific issue key reference) # ----------------------------------------------------------------------------- referenced_sensitive_projects contains proj if { jql := object.get(input.payload.args, "jql", "") is_string(jql) some proj in sensitive_projects pattern := sprintf(`(?i)\bproject\s*=\s*['"]?%s['"]?(\s|$|[^A-Z0-9_])`, [proj]) regex.match(pattern, jql) } referenced_sensitive_projects contains proj if { jql := object.get(input.payload.args, "jql", "") is_string(jql) some proj in sensitive_projects pattern := sprintf(`(?i)\bproject\s+in\s*\([^)]*['"]?%s['"]?[^)]*\)`, [proj]) regex.match(pattern, jql) } referenced_sensitive_projects contains proj if { jql := object.get(input.payload.args, "jql", "") is_string(jql) some proj in sensitive_projects pattern := sprintf(`\b%s-\d+\b`, [proj]) regex.match(pattern, jql) } jql_references_sensitive_project if { count(referenced_sensitive_projects) > 0 } # ----------------------------------------------------------------------------- # JQL rewriting helpers — find ORDER BY position and build the new JQL. # Handles four shapes: filter+ORDER BY, ORDER BY only, filter only, empty. # ----------------------------------------------------------------------------- # Index of "order by" in JQL. Returns the index of " order by " (with leading # space) if present mid-string, or 0 if JQL starts with "order by ". Otherwise # undefined — callers can use `not order_by_idx(...)` to mean "no ORDER BY". order_by_idx(jql) := idx if { lower_jql := lower(jql) idx := indexof(lower_jql, " order by ") idx >= 0 } order_by_idx(jql) := 0 if { lower_jql := lower(jql) indexof(lower_jql, " order by ") == -1 startswith(lower_jql, "order by ") } # Case 1: JQL has both a filter and an ORDER BY clause. build_jql(original_jql) := new_jql if { idx := order_by_idx(original_jql) filter_part := trim_space(substring(original_jql, 0, idx)) order_part := trim_space(substring(original_jql, idx, count(original_jql) - idx)) filter_part != "" new_jql := sprintf("%s AND (%s) %s", [exclusion_clause, filter_part, order_part]) } # Case 2: JQL has ONLY an ORDER BY clause, no filter. build_jql(original_jql) := new_jql if { idx := order_by_idx(original_jql) filter_part := trim_space(substring(original_jql, 0, idx)) order_part := trim_space(substring(original_jql, idx, count(original_jql) - idx)) filter_part == "" new_jql := sprintf("%s %s", [exclusion_clause, order_part]) } # Case 3: JQL has ONLY a filter, no ORDER BY. build_jql(original_jql) := new_jql if { not order_by_idx(original_jql) filter_part := trim_space(original_jql) filter_part != "" new_jql := sprintf("%s AND (%s)", [exclusion_clause, filter_part]) } # Case 4: JQL is empty. build_jql(original_jql) := exclusion_clause if { not order_by_idx(original_jql) trim_space(original_jql) == "" } # ----------------------------------------------------------------------------- # Deny rules # ----------------------------------------------------------------------------- allow := false if { some suffix in view_tool_suffixes endswith(tool_name, suffix) view_targets_sensitive_project } allow := false if { is_search_tool jql_references_sensitive_project } # ----------------------------------------------------------------------------- # Transform — silently exclude sensitive projects from generic JQL searches. # Only fires when the JQL does NOT already explicitly reference a sensitive # project; those calls are denied above with a clear reason. Generic searches # (no project filter, ORDER BY only, empty JQL, etc.) get the silent filter so # sensitive-project issues never appear in the result set. # ----------------------------------------------------------------------------- transform := { "transformed_payload": object.union( input.payload.args, {"jql": new_jql}, ) } if { input.action == "tool_pre_invoke" is_search_tool not jql_references_sensitive_project original_jql := object.get(input.payload.args, "jql", "") is_string(original_jql) new_jql := build_jql(original_jql) } # ----------------------------------------------------------------------------- # Reasons # ----------------------------------------------------------------------------- reasons contains msg if { some suffix in view_tool_suffixes endswith(tool_name, suffix) view_targets_sensitive_project key := object.get(input.payload.args, "issueIdOrKey", "") project := project_from_issue_key(key) msg := sprintf("Viewing issues in the '%s' project is not permitted. Contact your InfoSec team if this needs to change.", [project]) } reasons contains msg if { is_search_tool jql_references_sensitive_project project_list := concat(", ", sort([p | some p in referenced_sensitive_projects])) msg := sprintf("Searching for issues in protected project(s) (%s) is not permitted. Contact your InfoSec team if this needs to change.", [project_list]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### JIRA: Protect Sensitive Projects from Writes URL: https://www.intentbasedpolicy.com/policies/jira/deny-write-sensitive-projects App(s): jira | Direction: ingress | Bundles: atlassian | Package: jira.ingress.protect_sensitive_projects | Published: 2026-06-16 | Tags: jira, atlassian, access-control, data-protection, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/deny-write-sensitive-projects/policy.md # jira / deny-write-sensitive-projects **Direction:** ingress (`tool_pre_invoke`) **Default:** allow, with targeted denies on writes to sensitive projects **Package:** `jira.ingress.protect_sensitive_projects` ## What it does Blocks write operations against issues that belong to a configurable set of "sensitive" JIRA projects. It is the write-side companion to the read/search restriction policy: where that one keeps sensitive issues out of view, this one keeps callers from modifying, creating, moving, or linking them. Any tool that is not a write, and any project not in the sensitive set, passes through untouched. The set of sensitive projects is configured once at the top of the Rego (`sensitive_projects`) and all comparisons are case-insensitive. ## Behavior The policy is `default allow := true` and denies a call only when a write targets a sensitive project, across four families of operations: - **Direct writes** — edit, update, transition, assign, delete, archive/unarchive, set priority/labels, comment, worklog, attachment, watcher, and vote operations on an issue whose key resolves to a sensitive project (read from `issueIdOrKey`). - **Links** — link/unlink operations where either the inward or outward issue belongs to a sensitive project. - **Create** — creating an issue whose target project is sensitive. - **Move** — moving an issue into a sensitive project. ## Why ingress Writes have permanent side effects, so the only place to stop them is *before* the call reaches JIRA. Each violation is fully determined by the request (tool name + arguments), so ingress denial prevents the modification from ever executing. Pair this with the read-side [`deny-view-search-sensitive-projects`](../deny-view-search-sensitive-projects/policy.md) policy and the egress [`redact-sensitive-info`](../redact-sensitive-info/policy.md) policy for full read + write + leakage coverage of the same projects. ## Tool name matching Tool names on the gateway are prefixed with the configured MCP server name (e.g. `atlassian-jira-mcp-editjiraissue`), and that prefix is not standardized. The policy matches on the tool-name **suffix** (e.g. `-editjiraissue`, `-createjiraissue`, `-movejiraissue`) so it stays portable across naming conventions. Confirm the exact tool names your gateway emits with the dump-input debug technique, and extend the suffix sets if your JIRA MCP server exposes additional write tools. ## Target-project detection (create / move) Different Atlassian MCP variants ship the target project under different argument names. Rather than assume one shape, the policy collects every project key it can find and denies if any is sensitive: - **Create** — `fields.project.key` (REST-nested), `projectKey`, `projectIdOrKey`, `project_key`, and bare-string `project`. - **Move** — `targetProjectKey`, `targetProjectIdOrKey`, `target_project_key`, and `targetProject.key`. ## Configuration Edit the `sensitive_projects` set at the top of the policy. The shipped keys (`PROJECT_KEY1`, `PROJECT_KEY2`) are **placeholders** — replace them with your own project keys (uppercase). If you also run the read/search-restriction policy for the same projects, mirror this set there so read and write controls stay aligned. ## Examples ### Denied (editing a sensitive-project issue) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-jira-mcp-editjiraissue", "type": "tool" }, "payload": { "name": "atlassian-jira-mcp-editjiraissue", "args": { "issueIdOrKey": "PROJECT_KEY1-42", "fields": { "summary": "new" } } } } } ``` `allow = false`, `reason = "Modifying issues in the 'PROJECT_KEY1' project is not permitted. ..."`. ### Allowed (editing an issue in a non-sensitive project) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-jira-mcp-editjiraissue", "type": "tool" }, "payload": { "name": "atlassian-jira-mcp-editjiraissue", "args": { "issueIdOrKey": "DEV-7", "fields": { "summary": "new" } } } } } ``` `allow = true`, no reason. ## Composition This policy covers the write surface for the configured projects. Useful companions in the same [`atlassian`](../../../bundles/atlassian/README.md) bundle: - [`deny-view-search-sensitive-projects`](../deny-view-search-sensitive-projects/policy.md) — ingress read/search restriction for the same projects. - [`redact-sensitive-info`](../redact-sensitive-info/policy.md) — egress redaction of PII/secrets in returned issue content. ## Known limitations - **Numeric issue IDs are not covered.** Project membership is derived from the `KEY-NNN` form of an issue identifier. A call that references an issue purely by numeric ID carries no project information, so it falls through to allow. If your callers can act on issues by numeric ID, add a complementary control. - **New write tools require a suffix update.** Only the tools listed in the suffix sets are treated as writes. If the MCP server adds a new mutating tool, add its suffix so it is covered. - **No identity-based exemptions.** All callers are treated the same. To add an InfoSec break-glass user, gate a separate `allow if` branch on `input.subject.claims`. ```rego package jira.ingress.protect_sensitive_projects # Default-allow: only deny when a write tool targets a sensitive project. default allow := true # ----------------------------------------------------------------------------- # CONFIG: Sensitive project keys. Edit this set to add or remove projects. # Stored UPPERCASE — all comparisons normalize input to upper case. # ----------------------------------------------------------------------------- sensitive_projects := { "PROJECT_KEY1", #placeholder value, replace with your target project key "PROJECT_KEY2", #placeholder value, replace with your target project key } # ----------------------------------------------------------------------------- # WRITE TOOLS: any Atlassian tool whose name ends with one of these suffixes is # treated as a write operation. Suffix matching makes this work regardless of # the MCP server name on the gateway (atlassian-, atlassian-jira-mcp-, etc.). # ----------------------------------------------------------------------------- write_tool_suffixes := { "-editjiraissue", "-updatejiraissue", "-transitionjiraissue", "-assignjiraissue", "-deletejiraissue", "-archivejiraissue", "-unarchivejiraissue", "-movejiraissue", "-setjiraissuepriority", "-setjiraissuelabels", "-addjiraissuelabel", "-removejiraissuelabel", "-addcommenttojiraissue", "-editcommentonjiraissue", "-deletecommentfromjiraissue", "-addjiraissueattachment", "-removejiraissueattachment", "-addworklogtojiraissue", "-updateworklog", "-deleteworklog", "-addjiraissuewatcher", "-removejiraissuewatcher", "-voteforjiraissue", "-unvotejiraissue", } link_tool_suffixes := { "-linkjiraissues", "-createjiraissuelink", "-deletejiraissuelink", } create_tool_suffixes := { "-createjiraissue", } # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- tool_name := lower(input.resource.name) # Extract the project key from a JIRA issue key like "HR-123" -> "HR". # Returns undefined for numeric IDs or malformed keys (rule then falls through). project_from_issue_key(key) := project if { parts := split(key, "-") count(parts) >= 2 project := upper(parts[0]) project != "" } issue_in_sensitive_project if { key := object.get(input.payload.args, "issueIdOrKey", "") project := project_from_issue_key(key) sensitive_projects[project] } linked_issue_in_sensitive_project if { key := object.get(object.get(input.payload.args, "inwardIssue", {}), "key", "") project := project_from_issue_key(key) sensitive_projects[project] } linked_issue_in_sensitive_project if { key := object.get(object.get(input.payload.args, "outwardIssue", {}), "key", "") project := project_from_issue_key(key) sensitive_projects[project] } # ----------------------------------------------------------------------------- # CREATE target project — set of candidate project keys. # Different Atlassian MCP variants ship the target project under different arg # names. Rather than guess one shape, collect any project key we find across # every known location and let the deny rule check if any are sensitive. # Bug fix vs v1: v1 only checked the REST-nested fields.project.key path, # which silently failed when the tool accepts a flattened argument like # projectKey. That allowed create calls to slip through. # ----------------------------------------------------------------------------- # REST shape: args.fields.project.key (e.g. {"fields": {"project": {"key": "HR"}}}) create_target_candidates contains upper(p) if { fields := object.get(input.payload.args, "fields", {}) project_obj := object.get(fields, "project", {}) p := object.get(project_obj, "key", "") is_string(p) p != "" } # Flattened: args.projectKey create_target_candidates contains upper(p) if { p := object.get(input.payload.args, "projectKey", "") is_string(p) p != "" } # Flattened: args.projectIdOrKey (mirrors issueIdOrKey naming) create_target_candidates contains upper(p) if { p := object.get(input.payload.args, "projectIdOrKey", "") is_string(p) p != "" } # Snake-case variant: args.project_key create_target_candidates contains upper(p) if { p := object.get(input.payload.args, "project_key", "") is_string(p) p != "" } # Bare-string variant: args.project = "HR". is_string guard prevents collision # with the REST shape above (where args.project is an object). create_target_candidates contains upper(p) if { p := object.get(input.payload.args, "project", "") is_string(p) p != "" } # ----------------------------------------------------------------------------- # MOVE target project — same family of arg-shape variants as create. # ----------------------------------------------------------------------------- move_target_candidates contains upper(p) if { p := object.get(input.payload.args, "targetProjectKey", "") is_string(p) p != "" } move_target_candidates contains upper(p) if { p := object.get(input.payload.args, "targetProjectIdOrKey", "") is_string(p) p != "" } move_target_candidates contains upper(p) if { p := object.get(input.payload.args, "target_project_key", "") is_string(p) p != "" } move_target_candidates contains upper(p) if { target := object.get(input.payload.args, "targetProject", {}) p := object.get(target, "key", "") is_string(p) p != "" } # ----------------------------------------------------------------------------- # Deny rules # ----------------------------------------------------------------------------- allow := false if { some suffix in write_tool_suffixes endswith(tool_name, suffix) issue_in_sensitive_project } allow := false if { some suffix in link_tool_suffixes endswith(tool_name, suffix) linked_issue_in_sensitive_project } allow := false if { some suffix in create_tool_suffixes endswith(tool_name, suffix) some candidate in create_target_candidates sensitive_projects[candidate] } allow := false if { endswith(tool_name, "-movejiraissue") some candidate in move_target_candidates sensitive_projects[candidate] } # ----------------------------------------------------------------------------- # Reasons # ----------------------------------------------------------------------------- reasons contains msg if { some suffix in write_tool_suffixes endswith(tool_name, suffix) issue_in_sensitive_project key := object.get(input.payload.args, "issueIdOrKey", "") project := project_from_issue_key(key) msg := sprintf("Modifying issues in the '%s' project is not permitted. Contact your InfoSec team if this needs to change.", [project]) } reasons contains "Linking to or from an issue in a protected project is not permitted. Contact your InfoSec team if this needs to change." if { some suffix in link_tool_suffixes endswith(tool_name, suffix) linked_issue_in_sensitive_project } reasons contains msg if { some suffix in create_tool_suffixes endswith(tool_name, suffix) some project in create_target_candidates sensitive_projects[project] msg := sprintf("Creating issues in the '%s' project is not permitted. Contact your InfoSec team if this needs to change.", [project]) } reasons contains msg if { endswith(tool_name, "-movejiraissue") some project in move_target_candidates sensitive_projects[project] msg := sprintf("Moving issues into the '%s' project is not permitted. Contact your InfoSec team if this needs to change.", [project]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### JIRA: Redact Sensitive Information from Issue Views URL: https://www.intentbasedpolicy.com/policies/jira/redact-sensitive-info App(s): jira | Direction: egress | Bundles: atlassian | Package: jira.egress.redact_sensitive_info | Published: 2026-06-15 | Tags: jira, atlassian, pii, secrets, dlp, redaction, egress Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/redact-sensitive-info/policy.md # jira / redact-sensitive-info **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `jira.egress.redact_sensitive_info` ## What it does Redacts sensitive content from the responses of JIRA issue-view tools before they reach the caller. The goal is durable: prevent PII, credentials, and secrets from leaking out through JIRA issue bodies, comments, worklogs, and linked-issue content. It is a transform-only egress policy — it never denies a call, it only rewrites matching content in the response to `[REDACTED]`. Any tool that is not a JIRA issue-view tool passes through untouched. ## Why egress and not ingress The risk here is *reading* secrets that already live inside JIRA issues (pasted into a description, a comment, or a worklog). Those values exist regardless of this gateway, so there is nothing to block at ingress — the leak happens when the content is returned to an MCP client. Masking on the egress (response) path is the only place to catch it. ## Scope / tool matching The policy applies only to JIRA tools that surface issue-body content, matched by tool-name **suffix** so it stays portable regardless of the MCP server name prefix the gateway adds (`atlassian-`, `atlassian-jira-mcp-`, etc.): - `*-getjiraissue` - `*-searchjiraissuesusingjql` - `*-getcommentsforjiraissue` - `*-getjiraissuecomments` - `*-getworklogsforjiraissue` - `*-getjiraissueworklog` - `*-getjiraissueremoteissuelinks` The tool name is read from both `input.resource.name` and `input.tool_metadata.name`, so the policy matches regardless of which surface the gateway populates first. Confirm the exact tool names your gateway sends using the dump-input debug technique before relying on this in production; if your JIRA MCP server exposes other issue-view tools, add their suffixes to `view_tool_suffixes`. ## What gets redacted Redaction works two ways. **By pattern** in any string value: - PII — US SSN, credit card numbers, email addresses, US phone numbers - Cloud / SaaS keys (vendor-prefixed shapes) — AWS access keys (`AKIA…`, `ASIA…`), Google API keys (`AIza…`) and OAuth tokens (`ya29.…`), GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`), GitLab PATs (`glpat-…`), Slack tokens (`xox[abprs]-…`), Stripe keys (`sk_live_`, `sk_test_`, `pk_live_`, `pk_test_`) - OAuth / bearer — `Authorization: Bearer …` headers and JWTs - Generic `key: value` / `key=value` secret assignments (api_key, password, secret, token, client_secret, credentials, …) - Database connection strings with embedded credentials (postgres, mysql, mongodb, redis, amqp, mssql/sqlserver URIs; JDBC strings; ADO.NET-style `Server=…;User Id=…;Password=…`) - PEM private-key blocks (`-----BEGIN … PRIVATE KEY----- … -----END …-----`) And **by field name** — any response field whose key is one of `password`, `passwd`, `pwd`, `secret`, `api_key`, `apikey`, `token`, `access_token`, `refresh_token`, `id_token`, `client_secret`, `private_key`, `connection_string`, or `credentials`. Matches are replaced with `[REDACTED]`. ## Examples ### Redacted (JIRA issue view) ```jsonc { "input": { "action": "tool_post_invoke", "resource": { "name": "atlassian-jira-mcp-getjiraissue", "type": "tool" }, "tool_metadata": { "name": "atlassian-jira-mcp-getjiraissue" } } } ``` `allow = true`, with a `transform` that supplies the redaction patterns, field names, and `replacement = "[REDACTED]"` for the gateway to apply to the response body, plus `reason = "Sensitive content redacted from Jira response"` so the redaction is explained in the dashboard. ### Passed through (any non-issue-view tool) ```jsonc { "input": { "action": "tool_post_invoke", "resource": { "name": "atlassian-jira-mcp-getjiraproject", "type": "tool" }, "tool_metadata": { "name": "atlassian-jira-mcp-getjiraproject" } } } ``` `allow = true`, no `transform` — the response is returned unchanged. ## Composition This policy is single-purpose and transform-only, so it composes cleanly with access-control policies on the same egress pipeline. Useful companions: - An ingress policy that blocks *writing* secrets into JIRA in the first place (so new issues don't accumulate credentials). - Equivalent egress redaction policies for other Atlassian apps (Confluence, Bitbucket). See the [`bundles/atlassian`](../../../bundles/atlassian/README.md) bundle for the curated Atlassian set. ## Known limitations - **Regex over plain text.** Detection is pattern-based, so novel or non-standard token formats, short-lived rotating tokens, and custom-shaped secrets may not be caught. Treat this as a high-signal layer, not a complete DLP solution. - **Scope is issue-view tools only.** Other JIRA tools that might surface issue content (e.g. bulk export or attachment-download endpoints) are not inspected unless you add their suffixes to `view_tool_suffixes`. - **No identity-based exemptions.** All callers get the same redaction. Add an `input.subject.claims`-gated branch if a break-glass role needs raw values. ```rego package jira.egress.redact_sensitive_info # Transform-only policy — never denies, only redacts sensitive content. # Scoped to JIRA tools that return issue-body content. Other tools (including # non-JIRA egress) pass through this policy untouched. default allow := true # ----------------------------------------------------------------------------- # Scope: JIRA tools that surface issue content (summary, description, comments, # worklog, custom fields, linked issues). Suffix matching makes this work # regardless of the MCP server name (atlassian-, atlassian-jira-mcp-, etc.). # ----------------------------------------------------------------------------- view_tool_suffixes := { "-getjiraissue", "-searchjiraissuesusingjql", "-getcommentsforjiraissue", "-getjiraissuecomments", "-getworklogsforjiraissue", "-getjiraissueworklog", "-getjiraissueremoteissuelinks", } is_jira_issue_view if { some suffix in view_tool_suffixes name := lower(input.resource.name) endswith(name, suffix) } is_jira_issue_view if { # Egress hooks also expose the tool name under tool_metadata.name — check # both so we match regardless of which surface the gateway populates first. some suffix in view_tool_suffixes name := lower(object.get(input.tool_metadata, "name", "")) endswith(name, suffix) } # ----------------------------------------------------------------------------- # Redaction transform # ----------------------------------------------------------------------------- transform := { "redact_patterns": [ # ---- PII ---- `\d{3}-\d{2}-\d{4}`, # US SSN `\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}`, # Credit card `[\w.-]+@[\w.-]+\.[\w.-]+`, # Email `\+?1?[- .]?\(?\d{3}\)?[- .]?\d{3}[- .]?\d{4}`, # US phone # ---- Cloud / SaaS API keys (vendor-prefixed shapes) ---- `AKIA[0-9A-Z]{16}`, # AWS access key ID `ASIA[0-9A-Z]{16}`, # AWS temporary (STS) access key `AIza[0-9A-Za-z_-]{35}`, # Google API key `ya29\.[0-9A-Za-z_-]+`, # Google OAuth access token `ghp_[A-Za-z0-9]{36}`, # GitHub personal access token `gho_[A-Za-z0-9]{36}`, # GitHub OAuth token `ghu_[A-Za-z0-9]{36}`, # GitHub user-to-server token `ghs_[A-Za-z0-9]{36}`, # GitHub server-to-server token `ghr_[A-Za-z0-9]{36}`, # GitHub refresh token `glpat-[A-Za-z0-9_-]{20}`, # GitLab personal access token `xox[abprs]-[A-Za-z0-9-]+`, # Slack tokens (bot/app/user/refresh/etc.) `sk_live_[A-Za-z0-9]{24,}`, # Stripe live secret key `sk_test_[A-Za-z0-9]{24,}`, # Stripe test secret key `pk_live_[A-Za-z0-9]{24,}`, # Stripe live publishable key `pk_test_[A-Za-z0-9]{24,}`, # Stripe test publishable key # ---- OAuth / bearer ---- `(?i)bearer\s+[A-Za-z0-9._~+/-]+=*`, # Authorization: Bearer `eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`, # JWT (header.payload.signature, base64url) # ---- Generic key/value secret patterns ---- `(?i)(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*\S+`, # api_key=..., api-key: ... `(?i)(?:password|passwd|pwd|secret|token|credentials|client[_-]?secret)\s*[:=]\s*\S+`, # ---- Database connection strings ---- # URI form with embedded user:password (postgres, mysql, mongo, redis, amqp, mssql) `(?i)(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis(?:s)?|amqps?|mssql|sqlserver)://[^:\s]+:[^@\s]+@[^/\s]+(?:/\S*)?`, `(?i)jdbc:[a-z0-9]+:[^\s]+`, # JDBC strings `(?i)(?:Server|Data Source)\s*=\s*[^;]+;\s*(?:User Id|UID)\s*=\s*[^;]+;\s*(?:Password|PWD)\s*=\s*[^;]+`, # ---- PEM private keys (multi-line, lazy match between BEGIN/END markers) ---- `-----BEGIN [A-Z ]+PRIVATE KEY-----.+?-----END [A-Z ]+PRIVATE KEY-----`, ], "redact_fields": [ "password", "passwd", "pwd", "secret", "api_key", "apikey", "token", "access_token", "refresh_token", "id_token", "client_secret", "private_key", "connection_string", "credentials", ], "replacement": "[REDACTED]", } if { is_jira_issue_view } # Surfaced on the decision event whenever the redaction is in scope, so the # dashboard can explain the rewrite. reason := "Sensitive content redacted from Jira response" if { is_jira_issue_view } ``` ### Salesforce Protect Contact Fields URL: https://www.intentbasedpolicy.com/policies/salesforce/protect-contact-fields App(s): salesforce | Direction: ingress | Bundles: crm | Package: salesforce.ingress.protect_contact_fields | Published: 2026-06-16 | Tags: salesforce, contacts, pii, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/protect-contact-fields/policy.md # salesforce / protect-contact-fields **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `salesforce.ingress.protect_contact_fields` ## What it does Blocks Salesforce Contact updates that modify protected fields — ownership, account linkage, contact PII, name, and consent flags. Any `*-updatesobjectrecord` call targeting the `Contact` sobject whose request body includes one of the protected fields is denied, with a reason naming the offending fields. Updates to other Contact fields, updates to other sobjects, and all other tools pass through unchanged. ## Why ingress Field updates are writes with permanent side effects (ownership reassignment, consent/opt-out changes, PII edits). The violation is fully determined by the request body, so denying at ingress prevents the change from ever reaching Salesforce. ## How it matches All of the following must hold for a call to be denied: - **Tool match.** The (lowercased) tool name ends with `-updatesobjectrecord` (suffix matching keeps the policy portable regardless of the MCP server name prefix the gateway adds). - **Contact sobject.** The `sobject-name` argument is `contact` (case-insensitive). - **Protected field present.** The `body` argument contains at least one protected field (case-insensitive): `OwnerId`, `AccountId`, `Email`, `Phone`, `MobilePhone`, `HomePhone`, `OtherPhone`, `Fax`, `FirstName`, `LastName`, `DoNotCall`, `HasOptedOutOfEmail`, `HasOptedOutOfFax`. ## Tool naming on the gateway DTwo prefixes tool names with the MCP server name configured on the gateway, so a Salesforce server registered as `salesforce` surfaces `salesforce-updatesobjectrecord`. This policy matches on the **suffix** (`-updatesobjectrecord`) so it stays portable across naming conventions. Confirm the exact tool name with the dump-input debug technique before deploying. ## Examples ### Allowed (non-protected field) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-updatesobjectrecord", "type": "tool" }, "payload": { "name": "salesforce-updatesobjectrecord", "args": { "sobject-name": "Contact", "record-id": "003xx", "body": { "Description": "Met at conference" } } } } } ``` `allow = true`, no reason. ### Denied (protected field) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-updatesobjectrecord", "type": "tool" }, "payload": { "name": "salesforce-updatesobjectrecord", "args": { "sobject-name": "Contact", "record-id": "003xx", "body": { "Email": "new@example.com", "Description": "..." } } } } } ``` `allow = false`, `reason = "Updating these protected fields on a Contact is not permitted through this gateway: email."`. ## Known limitations - **`updatesobjectrecord` only.** The `updaterelatedrecord` tool (which updates a child record reached via a parent + relationship path) is not covered, because the target object isn't directly identifiable from its arguments. Add a companion policy if that path must also be restricted. - **Suffix tool-name match.** The policy matches any tool ending in `-updatesobjectrecord`. If a non-Salesforce MCP server exposed a tool with that same suffix, it would also be inspected — narrow the match if that is a concern in your environment. - **No identity-based exemptions.** All callers are treated the same. To allow a break-glass role to edit protected fields, add an `allow if` branch gated on `input.subject.claims`. ```rego package salesforce.ingress.protect_contact_fields default allow := false # Contact fields that may not be modified through this gateway (compared case-insensitively). protected_fields := { "ownerid", "accountid", "email", "phone", "mobilephone", "homephone", "otherphone", "fax", "firstname", "lastname", "donotcall", "hasoptedoutofemail", "hasoptedoutoffax", } # True when this call is a Contact update via updatesobjectrecord. is_contact_update if { endswith(lower(input.resource.name), "-updatesobjectrecord") lower(object.get(input.payload.args, "sobject-name", "")) == "contact" } # Pass through anything that is not a Contact update. allow if { not is_contact_update } # Allow a Contact update only when it touches none of the protected fields. allow if { is_contact_update count(offending_fields) == 0 } # The protected fields present in the update body. offending_fields := {lower(k) | some k in object.keys(object.get(input.payload.args, "body", {})) protected_fields[lower(k)] } reason := sprintf("Updating these protected fields on a Contact is not permitted through this gateway: %s.", [concat(", ", sort([f | some f in offending_fields]))]) if { is_contact_update count(offending_fields) > 0 } ``` ### Salesforce Query Allowlist URL: https://www.intentbasedpolicy.com/policies/salesforce/query-allowlist App(s): salesforce | Direction: ingress | Bundles: crm | Package: salesforce.ingress.query_allowlist | Published: 2026-06-16 | Tags: salesforce, access-control, data-protection, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/query-allowlist/policy.md # salesforce / query-allowlist **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `salesforce.ingress.query_allowlist` ## What it does Restricts Salesforce SOQL queries so only `Account`, `Contact`, and `Opportunity` records can be retrieved. It parses the primary object from the SOQL `FROM` clause and allows the call only when that object is in the allowlist. All other Salesforce tools and all non-Salesforce tools pass through untouched. ## Why ingress A query is a read whose scope is fully determined by the request (the SOQL string). Enforcing the allowlist at ingress prevents disallowed objects from ever being queried, rather than trying to filter results on the way back. ## How it matches - **Non-Salesforce tools** pass through (`not startswith("salesforce-")`). - **Other Salesforce tools** pass through — only `salesforce-soqlquery` is governed. - **SOQL queries** are allowed only when the primary `FROM` object (first `FROM` match, case-insensitive) is `account`, `contact`, or `opportunity`. ## Scope / tool naming This policy is scoped by the `salesforce-` server-name prefix and matches the query tool as `salesforce-soqlquery`, which assumes the Salesforce MCP server is registered on the gateway as `salesforce`. If your gateway registers it under a different name, adjust the `startswith`/tool-name checks. Confirm exact tool names with the dump-input debug technique before deploying. ## Examples ### Allowed (allowlisted object) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-soqlquery", "type": "tool" }, "payload": { "name": "salesforce-soqlquery", "args": { "q": "SELECT Id, Name FROM Account WHERE Industry = 'Tech'" } } } } ``` `allow = true`, no reason. ### Denied (non-allowlisted object) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-soqlquery", "type": "tool" }, "payload": { "name": "salesforce-soqlquery", "args": { "q": "SELECT Id, Username FROM User" } } } } ``` `allow = false`, `reason = "This gateway only permits querying Account, Contact, and Opportunity objects in Salesforce. Your query targets user."`. ## Known limitations - **SOQL tool only.** Only `salesforce-soqlquery` is governed — other read paths (`find`/SOSL, `listrecentsobjectrecords`, `getrelatedrecords`, `getobjectschema`) are not restricted. Add companion policies for full read coverage. - **Fail-safe parsing.** Queries whose primary object can't be parsed, and child-relationship subqueries in the SELECT list, are denied. - **No identity-based exemptions.** All callers get the same allowlist. Add an `input.subject.claims`-gated branch for a break-glass role. ```rego package salesforce.ingress.query_allowlist default allow := false # Objects permitted to be queried via SOQL. allowed_objects := {"account", "contact", "opportunity"} # Pass through any non-Salesforce tool. allow if { not startswith(lower(input.resource.name), "salesforce-") } # This policy only governs the SOQL query tool; all other Salesforce tools pass through. allow if { startswith(lower(input.resource.name), "salesforce-") lower(input.resource.name) != "salesforce-soqlquery" } # Allow a SOQL query only when its primary FROM object is in the allowlist. allow if { lower(input.resource.name) == "salesforce-soqlquery" allowed_objects[primary_object] } # Primary object = the first object named after a FROM clause (case-insensitive). primary_object := obj if { q := object.get(input.payload.args, "q", "") matches := regex.find_all_string_submatch_n(`(?i)\bfrom\s+([a-zA-Z_][a-zA-Z0-9_]*)`, q, -1) count(matches) > 0 obj := lower(matches[0][1]) } # Human-readable target for the denial message; falls back when the query can't be parsed. resolved_object := primary_object resolved_object := "an unrecognized or unparseable object" if not primary_object reason := sprintf("This gateway only permits querying Account, Contact, and Opportunity objects in Salesforce. Your query targets %s.", [resolved_object]) if { lower(input.resource.name) == "salesforce-soqlquery" not allow } ``` ### Salesforce Read-Only Access URL: https://www.intentbasedpolicy.com/policies/salesforce/read-only App(s): salesforce | Direction: ingress | Bundles: crm | Package: salesforce.ingress.readonly | Published: 2026-06-16 | Tags: salesforce, access-control, governance, read-only, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/read-only/policy.md # salesforce / read-only **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `salesforce.ingress.readonly` ## What it does Restricts the Salesforce MCP server to read-only access. Non-Salesforce tools pass through untouched; among Salesforce tools, only the known read tools (`soqlquery`, `find`, `getobjectschema`, `getrelatedrecords`, `getuserinfo`, `listrecentsobjectrecords`) are allowed. Any other `salesforce-*` tool — including current and future write tools like `createsobjectrecord`, `updatesobjectrecord`, `updaterelatedrecord` — is denied. ## Why an allowlist (fail-closed) This is an allowlist, not a blocklist: writes are denied by default and only named read tools are permitted. New write tools added to the MCP server in the future therefore fail closed (denied) rather than slipping through until someone remembers to blocklist them. ## Why ingress Writes have permanent side effects. The read/write nature of a call is fully determined by which tool is invoked, so denying non-read tools at ingress guarantees no mutation reaches Salesforce. ## How it matches - **Non-Salesforce tools** pass through (`not startswith("salesforce-")`). - **Salesforce read tools** in the allowlist are permitted. - **Everything else** under the `salesforce-` prefix is denied. ## Scope / tool naming This policy is scoped by the `salesforce-` server-name prefix and lists tools by their full `salesforce-*` names, which assumes the Salesforce MCP server is registered on the gateway as `salesforce`. If your gateway registers it under a different name, adjust the prefix and the allowlist entries. Confirm exact tool names with the dump-input debug technique before deploying. ## Examples ### Allowed (read tool) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-soqlquery", "type": "tool" }, "payload": { "name": "salesforce-soqlquery", "args": { "q": "SELECT Id FROM Account" } } } } ``` `allow = true`, no reason. ### Denied (write tool) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-createsobjectrecord", "type": "tool" }, "payload": { "name": "salesforce-createsobjectrecord", "args": { "sobject-name": "Account", "body": { "Name": "Acme" } } } } } ``` `allow = false`, `reason = "Salesforce write operations (create/modify) are blocked on this gateway. Only read-only Salesforce tools are permitted."`. ## Known limitations - **Allowlist maintenance.** New *read* tools must be added to `salesforce_read_tools` or they will be denied. This is the intended trade-off for fail-closed behavior on writes. - **Prefix-scoped.** Scoping is `startswith("salesforce-")` with full tool names. A server registered under a different prefix won't be governed until the checks are adjusted. - **No identity-based exemptions.** All callers are read-only. To allow a break-glass writer, add an `allow if` branch gated on `input.subject.claims`. ```rego package salesforce.ingress.readonly default allow := false # Salesforce read-only tools that are permitted salesforce_read_tools := { "salesforce-soqlquery", "salesforce-find", "salesforce-getobjectschema", "salesforce-getrelatedrecords", "salesforce-getuserinfo", "salesforce-listrecentsobjectrecords", } # This policy only governs the Salesforce MCP server. # Any non-Salesforce tool passes through untouched. allow if { not startswith(lower(input.resource.name), "salesforce-") } # Allow only the known Salesforce read-only tools. allow if { salesforce_read_tools[lower(input.resource.name)] } reason := "Salesforce write operations (create/modify) are blocked on this gateway. Only read-only Salesforce tools are permitted." if not allow ``` ### Salesforce Redact PII URL: https://www.intentbasedpolicy.com/policies/salesforce/redact-pii App(s): salesforce | Direction: egress | Bundles: crm | Package: salesforce.egress.pii_redaction | Published: 2026-06-16 | Tags: salesforce, pii, dlp, redaction, egress Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/redact-pii/policy.md # salesforce / redact-pii **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `salesforce.egress.pii_redaction` ## What it does Redacts personal contact information from Salesforce tool responses before they reach the caller. It is transform-only — it never denies a call, it only rewrites matching content to `[REDACTED]`. Any non-Salesforce tool, and any request that isn't on the output path, passes through untouched. `Name` and `Account` are intentionally left intact so records stay usable — extend `redact_fields` (e.g. add `Name`, `FirstName`, `LastName`) if full-PII redaction is required. ## Why egress The risk is *reading* PII that lives in Salesforce records (emails, phone numbers, mailing address, birthdate). Those values exist regardless of this gateway, so there is nothing to block at ingress — the leak happens when the content is returned to an MCP client. Masking on the egress (response) path is the only place to catch it. ## Scope / tool matching Applies to any tool whose (lowercased) name starts with `salesforce-`, on the output path (`input.mode == "output"`). If your Salesforce MCP server is registered under a different prefix, adjust the `startswith` check. Confirm the exact tool names with the dump-input debug technique before deploying. ## What gets redacted **By field name** — `Email`, `Phone`, `MobilePhone`, `HomePhone`, `OtherPhone`, `AssistantPhone`, `Fax`, `MailingStreet`, `MailingCity`, `MailingState`, `MailingPostalCode`, `MailingAddress`, `Birthdate` (matched case-insensitively). And **by pattern** in any string value: email addresses, US phone numbers (formatted and raw 10-digit), US SSNs, and 16-digit credit-card numbers. Matches are replaced with `[REDACTED]`. ## Examples ### Redacted (Salesforce tool response) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "salesforce-soqlquery", "type": "tool" } } } ``` `allow = true`, with a `transform` supplying the redaction patterns, field names, and `replacement = "[REDACTED]"` for the gateway to apply to the response body, plus `reason = "PII redacted from Salesforce response"` so the redaction is explained in the dashboard. ### Passed through (non-Salesforce tool, or not output path) A response from a non-`salesforce-` tool, or any request not on the output path, returns `allow = true` with no `transform` — unchanged. ## Known limitations - **Regex over text.** Detection is pattern-based, so novel formats may be missed and benign strings that look like a phone/email/card may be over-redacted. Treat this as a high-signal layer, not a complete DLP solution. - **Name/Account preserved by design.** These are not redacted so records stay identifiable; extend `redact_fields` if you need them masked. - **Prefix-scoped.** Scoping is `startswith("salesforce-")`. A server under a different prefix won't be covered until the check is adjusted. - **No identity-based exemptions.** All callers get the same redaction. Add an `input.subject.claims`-gated branch if a break-glass role needs raw values. ```rego package salesforce.egress.pii_redaction # Transform-only egress policy — never blocks, redacts PII from Salesforce responses. default allow := true transform := { "redact_patterns": [ # Email addresses "[\\w.+\\-]+@[\\w.\\-]+\\.[a-zA-Z]{2,}", # US phone numbers (formatted, requires separators between digit groups) "\\+?\\d{0,3}[\\s.\\-]?\\(?\\d{3}\\)?[\\s.\\-]\\d{3}[\\s.\\-]\\d{4}", # Raw 10-digit phone numbers (word-bounded to avoid matching longer IDs) "\\b\\d{10}\\b", # Social Security Numbers (XXX-XX-XXXX) "\\b\\d{3}-\\d{2}-\\d{4}\\b", # Credit card numbers (16 digits, optional separators) "\\b\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}[\\s\\-]?\\d{4}\\b" ], "redact_fields": [ "Email", "Phone", "MobilePhone", "HomePhone", "OtherPhone", "AssistantPhone", "Fax", "MailingStreet", "MailingCity", "MailingState", "MailingPostalCode", "MailingAddress", "Birthdate" ], "replacement": "[REDACTED]" } if { input.mode == "output" startswith(lower(input.resource.name), "salesforce-") } # Surfaced on the decision event whenever the redaction is in scope, so the # dashboard can explain the rewrite. reason := "PII redacted from Salesforce response" if { input.mode == "output" startswith(lower(input.resource.name), "salesforce-") } ``` ### Slack: Deny Channel Creation URL: https://www.intentbasedpolicy.com/policies/slack/deny-channel-creation App(s): slack | Direction: ingress | Bundles: slack | Package: slack.ingress.deny_channel_create | Published: 2026-06-16 | Tags: slack, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/deny-channel-creation/policy.md # slack / deny-channel-creation **Direction:** ingress (`tool_pre_invoke`) **Default:** allow, with a targeted deny on channel-creation calls **Package:** `slack.ingress.deny_channel_create` ## What it does Blocks Slack channel-creation tool calls at ingress. Every other Slack tool — and every non-Slack tool — passes through untouched. A blocked call returns a clear denial reason instead of creating a channel. ## Why ingress Creating a channel is a write with a permanent side effect on the workspace. The violation is fully determined by the request (the tool name alone), so denying at ingress prevents the channel from ever being created. ## How it matches Two conditions must both hold for a call to be denied: - **Slack-server scoping.** The first hyphen-separated segment of the tool name starts with `slack`. This matches `slack-...`, `slack-prod-...`, `slack-mcp-...`, etc., so the policy keeps working regardless of how the Slack MCP server is named on a given gateway. - **Create-channel detection.** The (lowercased) tool name ends with one of the known create-channel suffixes. Both common naming families are covered: - verb-first shapes: `-create-conversation`, `-create-channel`, and their `_`-separated and concatenated variants; - Slack-API-mirroring shapes: `-conversations.create`, `-conversations-create`, `-conversations_create`. Tool names on the gateway are prefixed with the configured MCP server name and that prefix is not standardized, which is why this matches on suffix rather than an exact name. Confirm the exact tool name your gateway emits with the dump-input debug technique before relying on this in production, and add any missing shape to `create_channel_suffixes`. ## Examples ### Denied (channel creation) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-conversations-create", "type": "tool" }, "payload": { "name": "slack-mcp-conversations-create", "args": { "name": "incident-2026-06" } } } } ``` `allow = false`, `reason = "Creating Slack channels is not permitted via this gateway. ..."`. ### Allowed (any other Slack tool) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack-post-message", "type": "tool" }, "payload": { "name": "slack-mcp-slack-post-message", "args": { "channel": "C123", "text": "hello" } } } } ``` `allow = true`, no reason. ## Composition Single-purpose and `default allow := true`, so it composes cleanly with other Slack ingress policies (e.g. [`block-secrets`](../block-secrets/policy.md)) on the same pipeline. ## Known limitations - **Suffix list, not an exhaustive catalog.** Only the create-channel tool shapes listed in `create_channel_suffixes` are matched. If a Slack MCP server exposes channel creation under a different tool name, add its suffix. - **No identity-based exemptions.** All callers are treated the same. To allow a specific admin/break-glass user to create channels, gate a separate `allow if` branch on `input.subject.claims`. ```rego package slack.ingress.deny_channel_create # Default-allow: only deny Slack channel-creation calls. default allow := true # ----------------------------------------------------------------------------- # Tool matching # ----------------------------------------------------------------------------- # Slack channel-creation tool names vary by MCP server implementation. Cover # the common shapes via endswith on the lowercased resource name. create_channel_suffixes := { # Verb-first ("create conversation/channel") — a common Slack MCP shape. "-create-conversation", "-create_conversation", "-createconversation", "-create-channel", "-createchannel", "-create_channel", # Slack-API-mirroring shape (conversations.create) — kept for MCP servers # that expose the underlying REST path more literally. "-conversations-create", "-conversations.create", "-conversations_create", } # Slack-server detection: the tool name's first hyphen-separated segment starts # with "slack". Matches "slack-...", "slack3-...", "slack-prod-...", etc., so # the policy keeps working regardless of how the MCP server is named on a # given gateway. is_slack_tool if { name := lower(input.resource.name) server_name := split(name, "-")[0] startswith(server_name, "slack") } is_create_channel if { is_slack_tool name := lower(input.resource.name) some suffix in create_channel_suffixes endswith(name, suffix) } # ----------------------------------------------------------------------------- # Deny rule # ----------------------------------------------------------------------------- allow := false if { is_create_channel } reason := "Creating Slack channels is not permitted via this gateway. Contact your InfoSec team if this needs to change." if not allow ``` ### Slack: Deny Read/Search/Summarize of Sensitive Channels URL: https://www.intentbasedpolicy.com/policies/slack/deny-read-search-summarize-sensitive-channels App(s): slack | Direction: ingress | Bundles: slack | Package: slack.ingress.deny_sensitive_channel_read | Published: 2026-06-16 | Tags: slack, access-control, data-protection, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/deny-read-search-summarize-sensitive-channels/policy.md # slack / deny-read-search-summarize-sensitive-channels **Direction:** ingress (`tool_pre_invoke`) **Default:** allow, with a targeted deny on sensitive-channel reads **Package:** `slack.ingress.deny_sensitive_channel_read` ## What it does Blocks read, search, and summarize operations that target a configurable set of "sensitive" Slack channels. Every other Slack tool — and every channel not in the set — passes through untouched. A blocked call returns a clear denial reason instead of returning channel contents. The set of sensitive channel IDs is configured once at the top of the Rego (`sensitive_channel_ids`). ## Why ingress The risk is *reading* content out of a protected channel, and that intent is fully visible in the request (the tool name plus the channel argument). Denying at ingress stops the read before it reaches Slack, so no protected message, thread, or summary is ever returned to the caller. ## How it matches A call is denied only when all three conditions hold: - **Slack-server scoping.** The first hyphen-separated segment of the tool name starts with `slack`, so the policy works regardless of how the Slack MCP server is named on a given gateway (`slack-...`, `slack-prod-...`, `slack-mcp-...`). - **Restricted-tool detection.** The (lowercased) tool name ends with one of the known suffixes across three operation families — channel history/replies reads, search, and summarize — with `-`, `_`, and concatenated naming variants covered. - **Sensitive-channel detection.** The channel is matched by **ID** against the configured set, both as a direct argument (`channel`, `channel_id`, `channelId`) and as a substring of search-query arguments (`query`, `q`), so channel-mention syntax inside a query (e.g. `in:<#C…>` or `<#C…|team-name>`) is also caught. Matching is on channel **IDs**, not names, because these tools receive resolved channel IDs at call time — a name-based approach does not fire. ID comparisons are case-sensitive, mirroring Slack's own behavior. Confirm the exact tool and argument names your gateway emits with the dump-input debug technique, and extend `restricted_tool_suffixes` or the channel-arg lookups if your Slack MCP server differs. ## Examples ### Denied (reading a sensitive channel's history) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-conversations-history", "type": "tool" }, "payload": { "name": "slack-mcp-conversations-history", "args": { "channel": "SLACK_CHANNEL_ID", "limit": 50 } } } } ``` `allow = false`, `reason = "Reading, searching, or summarizing this Slack channel is not permitted via this gateway. ..."`. ### Allowed (reading a non-sensitive channel) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-conversations-history", "type": "tool" }, "payload": { "name": "slack-mcp-conversations-history", "args": { "channel": "C0PUBLIC001", "limit": 50 } } } } ``` `allow = true`, no reason. ## Configuration Edit the `sensitive_channel_ids` set at the top of the policy. The shipped value (`SLACK_CHANNEL_ID`) is a **placeholder** — replace it with your real Slack channel IDs (uppercase, case-sensitive, e.g. `C0B5LHR8DQV`). ## Composition Single-purpose and `default allow := true`, so it composes cleanly with other Slack ingress policies (e.g. [`block-secrets`](../block-secrets/policy.md), [`deny-channel-creation`](../deny-channel-creation/policy.md)) on the same pipeline. ## Known limitations - **ID-based, not name-based.** The policy keys off channel IDs; it does not resolve channel names to IDs. Populate the ID set with the real IDs of the channels you need to protect. - **Suffix list, not an exhaustive catalog.** Only the tool shapes listed in `restricted_tool_suffixes` are matched. Add the suffix for any additional read/search/summarize tool your Slack MCP server exposes. - **No identity-based exemptions.** All callers are treated the same. To allow a specific break-glass user, gate a separate `allow if` branch on `input.subject.claims`. ```rego package slack.ingress.deny_sensitive_channel_read # Default-allow: only deny restricted operations on the configured channel IDs. default allow := true # ----------------------------------------------------------------------------- # CONFIG: Sensitive Slack channel IDs. Edit to add or remove. Slack channel # IDs are uppercase alphanumeric and case-sensitive (e.g. "C0B5LHR8DQV"). # ----------------------------------------------------------------------------- sensitive_channel_ids := { "SLACK_CHANNEL_ID", #placeholder value, replace with your Slack channel_id } # ----------------------------------------------------------------------------- # Slack-server detection: any tool whose first hyphen-separated segment starts # with "slack" (slack-, slack3-, slack-prod-, etc.). # ----------------------------------------------------------------------------- is_slack_tool if { name := lower(input.resource.name) server_name := split(name, "-")[0] startswith(server_name, "slack") } # ----------------------------------------------------------------------------- # Restricted-tool detection — covers read (channel history / replies), search, # and summarize operation families. Suffix matching tolerates different MCP # naming conventions across implementations. # ----------------------------------------------------------------------------- restricted_tool_suffixes := { # ---- Read: channel history / messages ---- "-conversations-history", "-conversations_history", "-conversationshistory", "-channel-history", "-channel_history", "-channelhistory", "-get-history", "-get_history", "-gethistory", "-get-messages", "-get_messages", "-getmessages", "-read-channel", "-read_channel", "-readchannel", # ---- Read: replies / threads ---- "-conversations-replies", "-conversations_replies", "-conversationsreplies", "-get-replies", "-get_replies", "-getreplies", "-thread-history", "-thread_history", "-threadhistory", # ---- Search ---- "-search-messages", "-search_messages", "-searchmessages", "-search-all", "-search_all", "-searchall", "-search-files", "-search_files", "-searchfiles", # ---- Summarize ---- "-summarize-channel", "-summarize_channel", "-summarizechannel", "-channel-summary", "-channel_summary", "-channelsummary", "-summarize-conversation", "-summarize_conversation", "-summarizeconversation", "-summarize", "-summary", } is_restricted_tool if { is_slack_tool name := lower(input.resource.name) some suffix in restricted_tool_suffixes endswith(name, suffix) } # ----------------------------------------------------------------------------- # Sensitive-channel-ID detection — match the channel arg against the # configured ID set, OR find an ID inside a search query. # # Slack channel IDs are uppercase letter+digit strings (e.g. C0B5LHR8DQV). # Comparisons are exact (case-sensitive) since the Slack API itself preserves # case. # ----------------------------------------------------------------------------- # Direct channel arg shapes channel_id_is_sensitive if { v := object.get(input.payload.args, "channel", "") sensitive_channel_ids[v] } channel_id_is_sensitive if { v := object.get(input.payload.args, "channel_id", "") sensitive_channel_ids[v] } channel_id_is_sensitive if { v := object.get(input.payload.args, "channelId", "") sensitive_channel_ids[v] } # Search-query substring check — catches Slack channel-mention syntax inside # queries (e.g. 'in:<#C0B5LHR8DQV>' or '<#C0B5LHR8DQV|fin-team>'). channel_id_is_sensitive if { q := object.get(input.payload.args, "query", "") is_string(q) some id in sensitive_channel_ids contains(q, id) } channel_id_is_sensitive if { q := object.get(input.payload.args, "q", "") is_string(q) some id in sensitive_channel_ids contains(q, id) } # ----------------------------------------------------------------------------- # Deny rule # ----------------------------------------------------------------------------- allow := false if { is_restricted_tool channel_id_is_sensitive } reason := "Reading, searching, or summarizing this Slack channel is not permitted via this gateway. Contact your InfoSec team if this needs to change." if not allow ``` ### Slack: Deny Sending Direct Messages URL: https://www.intentbasedpolicy.com/policies/slack/deny-direct-messages App(s): slack | Direction: ingress | Bundles: slack | Package: slack.ingress.deny_direct_messages | Published: 2026-06-16 | Tags: slack, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/deny-direct-messages/policy.md # slack / deny-direct-messages **Direction:** ingress (`tool_pre_invoke`) **Default:** allow, with a targeted deny on direct-message writes **Package:** `slack.ingress.deny_direct_messages` ## What it does Blocks Slack message-write calls whose destination resolves to a direct conversation — a 1:1 DM, a message posted to a user ID (which Slack auto-opens as a DM), or a multi-party/group DM. Posts to regular channels, and every non-write Slack tool, pass through untouched. A blocked call returns a clear denial reason instead of delivering the DM. ## Why ingress Sending a message is a write with a permanent side effect — once it reaches Slack the DM exists in the recipient's history. The destination is fully visible in the request, so denying at ingress prevents the message from ever being delivered. ## How it matches A call is denied only when all three conditions hold: - **Slack-server scoping.** The first hyphen-separated segment of the tool name starts with `slack`, so the policy works regardless of how the Slack MCP server is named on a given gateway (`slack-...`, `slack-prod-...`, `slack-mcp-...`). - **Write-tool detection.** The (lowercased) tool name ends with one of the known message-write suffixes — the post/send, update/edit, scheduled, ephemeral, and me-message families, with `-`, `_`, and concatenated naming variants covered. - **DM-recipient detection.** The destination is identified by Slack's ID-prefix conventions, not by name. On the channel argument (`channel`, `channel_id`, `channelId`) a value beginning with `D` (1:1 DM channel), `U`/`W` (user ID — Slack auto-opens a DM when posting to a user ID), or `G` (multi-party/group DM) is treated as a DM. A dedicated recipient argument (`user`, `user_id`, `userId`) is matched only against user IDs (`U`/`W`). Channel names and other non-matching ID shapes are intentionally **not** treated as DMs — the policy fails open on uncertainty rather than over-blocking. Confirm the exact tool and argument names your gateway emits with the dump-input debug technique, and extend `write_tool_suffixes` or the recipient lookups if your Slack MCP server differs. ## Examples ### Denied (posting to a 1:1 DM channel) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack-post-message", "type": "tool" }, "payload": { "name": "slack-mcp-slack-post-message", "args": { "channel": "D0123456789", "text": "hi" } } } } ``` `allow = false`, `reason = "Sending direct messages via Slack is not permitted via this gateway. ..."`. (A post addressed to a user ID such as `"channel": "U0123456789"` is denied the same way, since Slack would open a DM.) ### Allowed (posting to a regular channel) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack-post-message", "type": "tool" }, "payload": { "name": "slack-mcp-slack-post-message", "args": { "channel": "C0123456789", "text": "hi team" } } } } ``` `allow = true`, no reason. ## Composition Single-purpose and `default allow := true`, so it composes cleanly with other Slack ingress policies (e.g. [`block-secrets`](../block-secrets/policy.md), [`deny-channel-creation`](../deny-channel-creation/policy.md)) on the same pipeline. ## Known limitations - **ID-based detection.** Recipients are identified by Slack ID prefixes. A tool that accepts a DM destination as a plain name or under an unrecognized argument will not be matched — add the argument to the recipient lookups. - **Suffix list, not an exhaustive catalog.** Only the message-write shapes listed in `write_tool_suffixes` are matched. Add the suffix for any additional write tool your Slack MCP server exposes. - **No identity-based exemptions.** All callers are treated the same. To allow a specific break-glass user, gate a separate `allow if` branch on `input.subject.claims`. ```rego package slack.ingress.deny_direct_messages # Default-allow: only deny writes whose destination resolves to a direct # conversation (1:1 DM, user-ID-as-channel, MPIM, or user/userId arg). default allow := true # ----------------------------------------------------------------------------- # Slack-server detection: any tool whose first hyphen-separated segment starts # with "slack" (slack-, slack3-, slack-prod-, etc.). # ----------------------------------------------------------------------------- is_slack_tool if { name := lower(input.resource.name) server_name := split(name, "-")[0] startswith(server_name, "slack") } # ----------------------------------------------------------------------------- # Write-tool detection — covers common shapes for the message-posting family. # ----------------------------------------------------------------------------- write_tool_suffixes := { # postMessage family "-postmessage", "-post-message", "-post_message", "-sendmessage", "-send-message", "-send_message", # update / edit "-updatemessage", "-update-message", "-update_message", # scheduled posts "-schedulemessage", "-schedule-message", "-schedule_message", # ephemeral posts "-postephemeral", "-post-ephemeral", "-post_ephemeral", "-postephemeralmessage", "-post-ephemeral-message", # me-style messages "-memessage", "-me-message", "-me_message", } is_slack_write_tool if { is_slack_tool name := lower(input.resource.name) some suffix in write_tool_suffixes endswith(name, suffix) } # ----------------------------------------------------------------------------- # DM-recipient detection — match any destination value that resolves to a # direct conversation in Slack's ID conventions: # D... → 1:1 DM channel ID # U.../W... → user ID (Slack's chat.postMessage auto-opens a DM and posts # when the channel arg is a user ID) # G... → multi-party DM / group DM (in workspaces where private channels # moved to C, G is effectively MPIM-only) # Channel arg names checked: channel, channel_id, channelId. # Separately checks user/user_id/userId args for tools that take the recipient # under a dedicated user field. # Names (non-ID strings) and other ID shapes don't match — fail-open on # uncertainty. # ----------------------------------------------------------------------------- # DM via the channel arg (channel ID OR user ID auto-converted to DM). is_dm_recipient if { v := object.get(input.payload.args, "channel", "") regex.match(`^[DUWG][A-Z0-9]{7,}$`, v) } is_dm_recipient if { v := object.get(input.payload.args, "channel_id", "") regex.match(`^[DUWG][A-Z0-9]{7,}$`, v) } is_dm_recipient if { v := object.get(input.payload.args, "channelId", "") regex.match(`^[DUWG][A-Z0-9]{7,}$`, v) } # DM via a dedicated user arg (only user IDs are valid here). is_dm_recipient if { v := object.get(input.payload.args, "user", "") regex.match(`^[UW][A-Z0-9]{7,}$`, v) } is_dm_recipient if { v := object.get(input.payload.args, "user_id", "") regex.match(`^[UW][A-Z0-9]{7,}$`, v) } is_dm_recipient if { v := object.get(input.payload.args, "userId", "") regex.match(`^[UW][A-Z0-9]{7,}$`, v) } # ----------------------------------------------------------------------------- # Deny rule # ----------------------------------------------------------------------------- allow := false if { is_slack_write_tool is_dm_recipient } reason := "Sending direct messages via Slack is not permitted via this gateway. Contact your InfoSec team if this needs to change." if not allow ``` ### Slack: Redact Sensitive Information from Messages URL: https://www.intentbasedpolicy.com/policies/slack/redact-sensitive-info App(s): slack | Direction: ingress | Bundles: slack | Package: slack.ingress.redact_sensitive_info | Published: 2026-06-16 | Tags: slack, pii, secrets, dlp, redaction, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/redact-sensitive-info/policy.md # slack / redact-sensitive-info **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `slack.ingress.redact_sensitive_info` ## What it does Redacts sensitive content from outgoing Slack message arguments before the call reaches Slack. It is transform-only — it never denies a call, it only rewrites matching content to `[REDACTED]`. Any tool that is not a Slack tool, and any message with no matches, passes through untouched. ## Why ingress Sending a Slack message is a write with permanent side effects — once the call reaches Slack the content exists in channel history and may be syndicated to search, digests, and other members. Redacting on the request (ingress) path is the only way to keep the secret out of Slack entirely; an egress policy could only mask what is read back, not what was posted. ## Scope / tool matching Applies to any tool whose first hyphen-separated name segment starts with `slack`, so it works regardless of how the Slack MCP server is named on a given gateway (`slack-...`, `slack-prod-...`, `slack-mcp-...`). Confirm the exact tool names your gateway emits with the dump-input debug technique. ## Fields inspected - `text` and `message` — string bodies; redacted in place when they match. - `blocks` (Block Kit) and `attachments` (legacy) — serialized to JSON, byte- replaced, then reparsed. Only fields that actually contain a match are rewritten. Clean fields, absent fields, and all other arguments (`channel`, `thread_ts`, etc.) pass through unchanged. For `blocks`/`attachments`, if the replacement would produce invalid JSON the field's patch is silently omitted, so the policy never emits malformed arguments (fail-safe). ## What gets redacted A single alternation pattern covers: - **PII** — US SSN, credit-card numbers, email addresses, US phone numbers. - **Cloud / SaaS API keys (vendor-prefixed)** — AWS (`AKIA`/`ASIA`), Google (`AIza`, `ya29.`), GitHub (`ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_`), GitLab (`glpat-`), Slack (`xox[abprs]-`), Stripe (`sk_live_`/`sk_test_`/`pk_live_`/ `pk_test_`). - **OAuth / bearer** — `Authorization: Bearer ` and JWTs (`header.payload.signature`). - **Generic secrets** — `api_key`/`apikey`/`secret_key` and `password`/`secret`/`token`/`credentials`/`client_secret` assignments. - **Database connection strings** — URI (`postgres://`, `mysql://`, `mongodb+srv://`, `redis://`, `amqp://`, `mssql://`), JDBC, and ADO.NET (`Server=...;User Id=...;Password=...`). - **PEM private keys** — `-----BEGIN ... PRIVATE KEY----- ... -----END ...-----`. The pattern set is shared with the JIRA egress redaction policy (`jira.egress.redact_sensitive_info`). Tune it for your environment — add token shapes for providers you use, and remove patterns that are noisy for your traffic. ## Examples ### Redacted ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack-post-message", "type": "tool" }, "payload": { "name": "slack-mcp-slack-post-message", "args": { "channel": "C123", "text": "here's the api_key: sk-abcd1234..." } } } } ``` The `text` argument is rewritten to `here's the [REDACTED]`; `channel` is untouched. `allow = true`, plus `reason = "Sensitive content redacted from Slack message"` so the redaction is explained in the dashboard. ### Passthrough ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack-post-message", "type": "tool" }, "payload": { "name": "slack-mcp-slack-post-message", "args": { "channel": "C123", "text": "lunch in 5" } } } } ``` No match, no transform — args pass through unchanged. `allow = true`. ## Composition Transform-only and `default allow := true`, so it composes cleanly with deny policies on the same ingress pipeline (e.g. [`block-secrets`](../block-secrets/policy.md), [`deny-direct-messages`](../deny-direct-messages/policy.md)). `block-secrets` *blocks* a message that looks like it contains a secret; this policy *redacts* the secret and lets the message through — choose one posture per deployment, or order them deliberately if you attach both. ## Known limitations - **Regex over text.** Expect general-purpose false positives (e.g. an email- shaped substring inside a longer token) and false negatives (custom-format or short-lived secrets that match no known shape). Treat this as a high-signal first line of defense, not a complete DLP solution. - **Inspected fields are fixed.** Only `text`, `message`, `blocks`, and `attachments` are scanned. If your Slack MCP server carries body content under another argument, add a corresponding patch rule. ```rego package slack.ingress.redact_sensitive_info # Transform-only policy: redacts sensitive content from outgoing Slack message # args before the call reaches Slack. Never denies. Uses the same pattern set # as the JIRA egress redact policy (jira.egress.redact_sensitive_info). default allow := true # ----------------------------------------------------------------------------- # Tool matching — any tool whose server-name segment starts with "slack". # Matches "slack-...", "slack3-...", "slack-prod-...", etc. # ----------------------------------------------------------------------------- is_slack_tool if { name := lower(input.resource.name) server_name := split(name, "-")[0] startswith(server_name, "slack") } # ----------------------------------------------------------------------------- # Sensitive-content regex — every shape we want to redact, joined into a # single alternation so one regex.replace call covers them all per field. # (?i:...) groups scope case-insensitive matching to specific alternatives so # vendor-prefixed shapes (AKIA, ghp_, sk_live_, etc.) stay case-sensitive. # ----------------------------------------------------------------------------- sensitive_pattern := concat("|", [ # ---- PII ---- `\d{3}-\d{2}-\d{4}`, # US SSN `\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}`, # Credit card `[\w.-]+@[\w.-]+\.[\w.-]+`, # Email `\+?1?[- .]?\(?\d{3}\)?[- .]?\d{3}[- .]?\d{4}`, # US phone # ---- Cloud / SaaS API keys (vendor-prefixed) ---- `AKIA[0-9A-Z]{16}`, # AWS access key ID `ASIA[0-9A-Z]{16}`, # AWS temporary (STS) access key `AIza[0-9A-Za-z_-]{35}`, # Google API key `ya29\.[0-9A-Za-z_-]+`, # Google OAuth access token `ghp_[A-Za-z0-9]{36}`, # GitHub personal access token `gho_[A-Za-z0-9]{36}`, # GitHub OAuth token `ghu_[A-Za-z0-9]{36}`, # GitHub user-to-server token `ghs_[A-Za-z0-9]{36}`, # GitHub server-to-server token `ghr_[A-Za-z0-9]{36}`, # GitHub refresh token `glpat-[A-Za-z0-9_-]{20}`, # GitLab personal access token `xox[abprs]-[A-Za-z0-9-]+`, # Slack tokens (bot/app/user/refresh/etc.) `sk_live_[A-Za-z0-9]{24,}`, # Stripe live secret key `sk_test_[A-Za-z0-9]{24,}`, # Stripe test secret key `pk_live_[A-Za-z0-9]{24,}`, # Stripe live publishable key `pk_test_[A-Za-z0-9]{24,}`, # Stripe test publishable key # ---- OAuth / bearer ---- `(?i:bearer\s+[A-Za-z0-9._~+/-]+=*)`, # Authorization: Bearer `eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`, # JWT (header.payload.signature) # ---- Generic key=value secret patterns ---- `(?i:(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*\S+)`, `(?i:(?:password|passwd|pwd|secret|token|credentials|client[_-]?secret)\s*[:=]\s*\S+)`, # ---- Database connection strings ---- `(?i:(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis(?:s)?|amqps?|mssql|sqlserver)://[^:\s]+:[^@\s]+@[^/\s]+(?:/\S*)?)`, `(?i:jdbc:[a-z0-9]+:[^\s]+)`, `(?i:(?:Server|Data Source)\s*=\s*[^;]+;\s*(?:User Id|UID)\s*=\s*[^;]+;\s*(?:Password|PWD)\s*=\s*[^;]+)`, # ---- PEM private keys ---- `-----BEGIN [A-Z ]+PRIVATE KEY-----.+?-----END [A-Z ]+PRIVATE KEY-----`, ]) replacement := "[REDACTED]" # ----------------------------------------------------------------------------- # Per-field patches — each is added only when this is a Slack tool, the field # exists, and it contains at least one match. Fields without matches (and # absent fields) are left untouched in the final args. # ----------------------------------------------------------------------------- patches[k] := v if { k := "text" is_slack_tool original := object.get(input.payload.args, k, "") is_string(original) regex.match(sensitive_pattern, original) v := regex.replace(original, sensitive_pattern, replacement) } patches[k] := v if { k := "message" is_slack_tool original := object.get(input.payload.args, k, "") is_string(original) regex.match(sensitive_pattern, original) v := regex.replace(original, sensitive_pattern, replacement) } # Block Kit blocks: serialize → byte-replace → reparse. If the byte-replace # produces invalid JSON (possible if a pattern chews across boundaries), the # unmarshal fails and this patch is silently omitted — fail-safe rather than # emitting malformed args. patches[k] := v if { k := "blocks" is_slack_tool original := object.get(input.payload.args, k, null) original != null serialized := json.marshal(original) regex.match(sensitive_pattern, serialized) v := json.unmarshal(regex.replace(serialized, sensitive_pattern, replacement)) } # Legacy attachments: same treatment as blocks. patches[k] := v if { k := "attachments" is_slack_tool original := object.get(input.payload.args, k, null) original != null serialized := json.marshal(original) regex.match(sensitive_pattern, serialized) v := json.unmarshal(regex.replace(serialized, sensitive_pattern, replacement)) } # ----------------------------------------------------------------------------- # Apply the transform only when at least one field needs redaction. # object.union overwrites only the keys present in `patches`; every other arg # (channel_id, thread_ts, etc.) passes through unchanged. # ----------------------------------------------------------------------------- transform := { "transformed_payload": object.union(input.payload.args, patches) } if { is_slack_tool count(patches) > 0 } # Surfaced on the decision event whenever a patch is applied, so the dashboard # can explain the rewrite. reason := "Sensitive content redacted from Slack message" if { is_slack_tool count(patches) > 0 } ```