Force Internal Visibility on JSM Comments
Keeps agent-drafted Jira Service Management (JSM) comments off the customer-facing portal by rewriting addCommentToJiraIssue calls to carry a restrictive…
- Direction
- ingress
- Rego package
jira.ingress.force_internal_jsm_comments- App
- jira
- Bundles
- soc2atlassian
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:896209cbb84a0cfa530e5a7f86fd623e685efab156edcb59db1d9863f85bead8
jiraforce-internal-commentscommentsjsmservice-managementingresssoc2atlassian
What this policy does
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
addCommentToJiraIssueto injectcommentVisibility: {type: "role", value: "Service Desk Team"}when all of these hold: theissueIdOrKeybelongs to a configured JSM project (its key prefix is in the placeholder setSUP/HELP/ITSM), the caller is not in the placeholdersupport-agentsIdP group, and the call does not already carry a restrictivecommentVisibility. - Passes through unmodified any call that already carries a restrictive
commentVisibility(typeisroleorgroupwith a non-emptyvaluethat does not name a customer-facing audience such as the default JSMService Desk Customersrole) — 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-agentsgroup — 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: <string>}. 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_spaced 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)
{
"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)
{
"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)
{
"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)
{
"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 calladdCommentToJiraIssue(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/ITSMand the exemption group assupport-agents. Replace both with your own project keys and IdP group name at import time. Group names are placeholders — replacesupport-agentswith your IdP's group name at import time. The injectedvalue"Service Desk Team"is the default JSM Service Desk Team project role name; if your project uses a different role/group for internal comments, changeinternal_visibilityaccordingly. groupsclaim shape. Thegroupsclaim is assumed to be an array of strings. A caller with nogroupsclaim (or nosubjectat 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, adaptis_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 deniesaddCommentToJiraIssuecalls whoseissueIdOrKeyis not an alphabeticKEY-nnnshape, or resolve the ID→project mapping upstream. - Key normalisation is whitespace-only — embedded junk is a residual. The project prefix is
trim_spaced, 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-nnnshapes. - Comment-via-transition escape hatch — documented residual. The official
transitionJiraIssuetool accepts openfields/updateobjects (verified in the landscape note), and Jira's transition API adds a comment viaupdate.comment[].add— with its own optional visibility. A comment posted that way never passes throughaddCommentToJiraIssueand 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 stripsupdate/fieldspayloads on*transitionjiraissue(see the landscape note's publication-control candidate) to close this. OfficialeditJiraIssueexposes only afieldsobject, and Jira does not accept comment adds viafields, 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 thecustomer_facing_valuesplaceholder 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; extendcustomer_facing_valueswith your site's customer-containing roles/groups at import time. - Community
jira_add_comment/jira_edit_commentare not matched.sooperset/mcp-atlassiannames its comment toolsjira_add_commentandjira_edit_commentwith 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
commentVisibilityis overwritten — wholesale. If a call supplies acommentVisibilitythat is malformed (including a non-object value), uses an unrecognisedtype(e.g.type: "public"), carries an empty/whitespace-onlyvalue, 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-formedrole/groupvisibility 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)
argspass through unmodified. If a caller sendsargsas a string or array, theobject.getchain is undefined on a non-object, so the rewrite never fires and the call passes through. Such a call cannot carry a validcommentBody/issueIdOrKeyand fails at the server rather than posting a portal-visible comment. Pair withrole-gate-writesor 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-writesif some callers should not be able to comment at all. - Identity placeholders. Group names are placeholders — replace
support-agentswith 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.
Policy source (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
} 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