Salesforce Guard Opportunity Pipeline Fields
Keeps revenue-pipeline moves human-approved.
- Direction
- ingress
- Rego package
salesforce.ingress.guard_opportunity_pipeline- App
- salesforce
- Bundle
- crm
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:92b4111bcfd5fade1e78e5600bffbebaccb0d78b7f7dcd3f3c431a98fd895721
salesforceopportunitypipelinerevenuehuman-approvalaccess-controlgovernanceingress
What this policy does
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 thesobject-nameargument and, when it isOpportunity(case-insensitive), scans thebodyfield map. - tsmztech (
salesforce_dml_records): applies only whenoperationisupdateorupsert(compared case-insensitively and with surrounding whitespace stripped); readsobjectNameand scans each entry ofrecords[]. - smn2gnt (
update_record,bulk_update_records): readsobject_typeand scansdata(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-managersdoes. - 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 onoperation ∈ {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 underbody(a field map). - tsmztech: object under
objectName, operation underoperation, records underrecords(an array of field maps). - smn2gnt: object under
object_type, fields underdata(a field map, or an array of them forbulk_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)
{
"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)
{
"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):
protect-contact-fields— the same object-scoped write guard for theContactobject (ownership, PII, consent). No overlap: this policy governs onlyOpportunitypipeline fields.deny-escape-hatches— blocksapex_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 withdeny-escape-hatches. - Hosted
updateRelatedRecord(sobject-mutations) not covered. Likeprotect-contact-fields, this policy matches the three named hosted update tools; the relationship-scopedupdateRelatedRecordvariant 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, andCloseDatealready set (e.g. a Closed-Won deal born at creation). This affects every family: hostedcreateSobjectRecord/createRecord, smn2gntcreate_record/bulk_create_records, and tsmztechsalesforce_dml_recordswithoperation: "insert". Creation belongs torole-gate-writes/ an object-allowlist policy, not to pipeline-move control — pair with those to fence Opportunity creation. Relatedly, asalesforce_dml_recordscall that omitsoperationentirely 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-managerswith 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.
Policy source (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 Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Related policies
Freeze Salesforce Record Deletes
Denies all Salesforce record-deletion capability on the agent channel unless the caller's IdP groups claim contains the placeholder group sf-admins.
HubSpot Block Deal Closure
Blocks HubSpot CRM-object calls that move a deal into a closed stage (closedwon or closedlost). Both create and update requests are inspected.
HubSpot Cap Bulk Export
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…
hubspotcap-bulk-exportpiidata-minimisationingresssoc2hipaapci-dssgdpr-ccpa
HubSpot Freeze Destructive Ops
Blocks every archive/deletion-class HubSpot tool call, plus the consent-destroying contact unsubscribe, before it reaches the MCP server.
HubSpot Protect Associations
Blocks HubSpot CRM-object calls that create or change associations between objects (deal↔company, contact↔company, etc.).
HubSpot Protect Deal Owner
Blocks HubSpot CRM-object update calls that set or change a deal's owner.