Salesforce Cap Bulk Data Export
Blocks bulk PII extraction through Salesforce query tools by inspecting the free-text query arguments that are the real policy surface for these servers.
- Direction
- ingress
- Rego package
salesforce.ingress.cap_bulk_export- App
- salesforce
- Bundles
- crmsoc2hipaapci-dssgdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:753cf879feab22a8813df3861a2c69f898289c594370b2d06457d2e73c09f1cc
salesforcecap-bulk-exportdata-minimizationdlpingresssoc2hipaapci-dssgdpr-ccpa
What this policy does
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 thequerystring argument (SOQL is case-insensitive, so the patterns are too). When the query reads from the PII-heavyContact,Lead, orAccountobjects, it must carry a trailingLIMITclause of 1000 or fewer rows. A PII read with noLIMIT, or aLIMITabove 1000, is denied. Queries against other objects pass through — object pinning isquery-allowlist'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 IdPgroupsclaim includes the placeholder groupsalesorsupport.
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/supportgroups) 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) 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(hostedsobject-all),executequery(sobject-mutations),querysobjects(sobject-deletes),run_soql_query(smn2gnt / beta-era),salesforce_query_records(tsmztech). - SOSL suffixes:
find(hostedsobject-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 hostedsoql_query, communityrun_soql_query, andsalesforce_query_recordstools use). Some community servers expose the string underqorsoql; if yours does, add that key tosoql_query_textinpolicy.md. - SOSL: the search text is read from
input.payload.args.search. - The
sales/supportexemption readsinput.subject.claims.groupsviaobject.getchains with an empty-array default, and requires the claim to be an array of strings (is_array+is_stringguards). A missing subject, missing claims, missinggroupsclaim, a bare-stringgroups, an object/mapgroups(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 theis_arrayguard 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
LIMITinside a child-relationship subquery ((SELECT … FROM Contacts LIMIT 5000)) sits before the outerFROM/LIMIT, so it is not mistaken for the outer row cap — a PII read whose onlyLIMITis inside a subquery is treated as unbounded and denied. - A
LIMIT nembedded in aWHEREstring 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
{
"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
{
"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
{
"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— pins the SOQLFROMobject 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— 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/supportwith your IdP's group names at import time. The exemption readsinput.subject.claims.groups(array of strings) and fails closed: no IdP, no claim, a bare-stringgroups, or an object/mapgroupsmeans nobody is exempt from the SOSL deny (the rule requiresis_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 withquery-allowlistfor 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
FROMobject is not caught by this policy (it is not a recognized PII read); rely onquery-allowlist, which denies unparseable/non-allowlistedFROMobjects, for that backstop. query/searchargument keys assumed. SOQL readsargs.query, SOSL readsargs.search. Servers that useq/soqlor a different search key are not inspected until you add the key. A matched tool with the expected key missing fails closed (deny).findis a generic suffix. The hosted SOSL tool is literallyfind, so the suffix match can also catch afind-suffixed tool on an unrelated MCP server on the same gateway. The failure mode is over-blocking (that search also needssales/support), never under-blocking.- Escape hatches bypass this entirely.
apex_execute,tooling_execute,restful, andsalesforce_execute_anonymouscan 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.
Policy source (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)
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Related policies
Airtable: Redact PII in Record Reads
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…
Asana: Redact PII in Task & Comment Reads
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,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
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…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
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…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa