---
name: Force Internal Visibility on JSM Comments
tags:
  - jira
  - force-internal-comments
  - comments
  - jsm
  - service-management
  - ingress
  - soc2
  - atlassian
publishedAt: 2026-07-12
description: |
  # 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: <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_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.
direction: ingress
apps:
  - jira
industries: []
bundles:
  - soc2
  - atlassian
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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
}
```
