dtwo Policy Store

JIRA: Cap Field and Result Exposure on Reads

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:

Direction
ingress
Rego package
jira.ingress.cap_read_field_exposure
App
jira
Bundles
soc2gdpr-ccpaatlassian
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:637a57a8d7b022024175ead61be45bbac0f1c2fbd2e9e9302077df36694c24ef

jiraatlassiancap-bulk-exportdata-minimisationingresssoc2gdpr-ccpa

What this policy does

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

{
  "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

{
  "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:

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.

Policy source (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
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)