dtwo Policy Store

Linear: Redact Customer Revenue and Contacts

Masks commercial and contact identifiers in the responses of Linear's Customers read tools before they reach the agent.

Direction
egress
Rego package
linear.egress.redact_customer_data
App
linear
Bundles
gdpr-ccpasoc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:6d1736a372ac5e9534d04bd847cf125225eaa957c95d9f81b6dd0f1d7ab49c6a

linearredact-piipiidlpredactionegressgdpr-ccpasoc2

What this policy does

Direction: egress (tool_post_invoke) Default: allow (transform-only — never blocks the read) Package: linear.egress.redact_customer_data

What it does

Masks commercial and contact identifiers in the responses of Linear's Customers read tools before they reach the agent. On responses from *getCustomers, *getCustomerNeeds, and *getCustomerTiers the policy rewrites three field classes to fixed redaction tokens and leaves the rest of the record — customer name, need text, IDs, timestamps — intact:

Field class Matched JSON key Token
Revenue (any key containing revenue, e.g. revenue, annualRevenue) string, object (up to one level of nesting), or numeric value [REDACTED-REVENUE]
Tier / segment (any key containing tier, e.g. tier, customerTier, tierName) string, object (up to one level of nesting), or numeric value [REDACTED-TIER]
Contact email (any key containing email, e.g. email, contactEmail) string value [REDACTED-EMAIL]

A generic email-address sweep also runs over the response text, so a contact email embedded in prose (e.g. a customer need that reads "follow up with buyer@bigco.com") is masked even when it is not under an email key.

Linear's Customers feature links customer records, needs, tiers, and revenue to issues, so an unfiltered read egresses commercial financial data (deal-size / ARR) and contact PII (external buyer email addresses). This policy targets that linkable set while keeping the record usable: the agent can still reason over customer names and needs without seeing revenue, tier, or contact emails.

The policy is transform-only (default allow := true): it never denies a call, so a legitimate customer lookup still succeeds — it just comes back with those fields masked and the record structure intact. Responses with no matches, and all out-of-scope tools, pass through byte-identical. Every field is read via object.get chains, so a missing or oddly-shaped payload is never an error — it simply passes through.

Why egress and not ingress

The revenue, tier, and contact data live in the response, not the request: a customer-read tool's arguments (a customer ID, a filter, a page cursor) don't reveal ARR or buyer emails — only the returned records do. Ingress can't see what a read will surface, so redaction has to happen on the way back. The read itself is harmless and is allowed to proceed. Gating which customer tools can be called at all is a separate concern for a companion ingress policy.

Compliance alignment

  • SOC 2 CC6.7 — supports restricting the transmission/movement of confidential information by masking commercial and contact identifiers in Linear customer reads as they leave the gateway toward the agent (PF-02). C1.1 / P4.1 / P6.1 — supports identifying and protecting confidential information, limiting personal-information use to identified purposes (the agent gets working records without the identifiers it doesn't need), and constraining personal-information disclosure to third parties (here, the agent) — all Partial on the MCP path.
  • GDPR Art. 5(1)(c) — data minimisation on agent reads of customer personal data: the record comes back without the contact identifiers not needed for the task. Art. 5(1)(f) / Art. 32 — supports security of processing on the agent channel.
  • CCPA/CPRA §1798.121 — supports the consumer right to limit use of sensitive personal information by keeping contact identifiers out of agent context; §1798.150 — reduces nonredacted-PI breach exposure if agent context or downstream logs are later compromised.

Tool name matching

Applies on the output path to the Customers read channels, matched case-insensitively by suffix across three egress surfaces — input.resource.name, input.tool_metadata.name, and input.payload.name — so a gateway that populates a different surface can't slip a read past the scanner. Suffix matching keeps the policy portable across the gateway server-name prefix, which is not standardised.

  • *getCustomers — customer records (name, revenue, tier, contacts)
  • *getCustomerNeeds — customer needs linked to issues
  • *getCustomerTiers — customer tier definitions

These three names are verified from the tacticlaunch/mcp-linear TOOLS.md community inventory (linear_ + camelCase). The official Linear remote server also reads customer data per third-party catalogs, but its exact customer-read tool names are unverified in the landscape note — see Known limitations. Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production.

Response shape

The policy reads input.payload.text — the MCP content-block array the gateway populates on tool_post_invoke — and rewrites each string block. Linear MCP tools return serialized JSON in those blocks, so the field rewrites use key-anchored patterns ("revenue": …, "...tier...": …, "email": …) that replace only the value and keep the surrounding JSON valid and parseable. Non-string blocks pass through unmodified. When at least one block changes, the policy emits transform.transformed_payload containing the original payload with the rewritten text array (all other payload keys preserved). When nothing changes, no transform is emitted and the response passes through byte-identical.

Examples

Redacted (in-scope customer read)

{
  "input": {
    "action": "tool_post_invoke",
    "mode": "output",
    "resource": { "name": "linear-mcp-linear_getCustomers", "type": "tool" },
    "payload": {
      "name": "linear-mcp-linear_getCustomers",
      "text": ["{\"id\":\"cus_1\",\"name\":\"Acme Corp\",\"revenue\":1500000,\"tier\":\"Enterprise\",\"contacts\":[{\"name\":\"Jane Roe\",\"email\":\"jane@acme.com\"}]}"]
    }
  }
}

allow = true, with transform.transformed_payload.text = ["{\"id\":\"cus_1\",\"name\":\"Acme Corp\",\"revenue\":\"[REDACTED-REVENUE]\",\"tier\":\"[REDACTED-TIER]\",\"contacts\":[{\"name\":\"Jane Roe\",\"email\":\"[REDACTED-EMAIL]\"}]}"]. Customer and contact names survive; revenue, tier, and email are masked.

Passed through (out-of-scope tool)

A linear_getIssues / linear_getProjects response does not end in a Customers-read suffix, so transform is undefined and the aggregator skips this policy for that call.

Composition

Single-purpose transform policy (default allow := true); it composes cleanly with deny/transform policies on the same pipeline. Recommended companions for apps/linear:

  • A roadmap/initiative egress gate for pre-announcement product data.
  • A membership + impersonation deny on the write path.
  • A default-deny-unknown-tools ingress guard to catch drift as Linear's customer toolset expands.

Egress transforms attached to the same direction compose in pipeline order.

Known limitations

  • No identity exemption — all callers get the redacted view. This policy does not gate redaction on IdP group: every caller sees revenue, tier, and contact emails masked. If an authorized group (e.g. sales-ops) needs the raw values, add a not is_exempt guard to the transform rule that reads input.subject.claims.groups via object.get chains (see the Stripe/Docusign redact policies for the fail-closed pattern) — never rely on stripped ContextForge-internal claims (is_admin, teams, user).
  • Field key names are documented, not schema-verified. The tokens key on JSON keys containing revenue, tier, or email (case-insensitively). Those key names are documented in the landscape note, not confirmed against a live customer-read schema. Confirm the actual keys your deployment returns with a captured response (dump-input technique) and extend the patterns if Linear names them differently (e.g. arr, mrr, segment, contact_email is covered; arr/mrr/segment are not).
  • Official-server tool names are unverified. Only the three tacticlaunch names (getCustomers, getCustomerNeeds, getCustomerTiers) are verified. The official Linear remote server reads customer data too, but its exact tool names are unverified in the landscape note and are therefore not in scope. Pin them into customer_read_suffixes once confirmed via a live tools/list.
  • Deeply nested and array-valued revenue/tier are residuals. The revenue and tier object branches mask an object value including one level of nested braces (e.g. "revenue":{"amount":{"value":…}} and "tier":{"meta":{…}} are masked whole — red-team hardening, 2026-07). Two residuals remain and pass through unredacted: (a) an object nested two or more levels deep ("revenue":{"a":{"b":{"c":…}}}), and (b) a value expressed as a JSON array ("tier":["Enterprise"], "revenue":[…]), because the object pattern matches braces, not brackets. Both shapes are unusual for a money/tier field; extend the patterns if your response nests that deeply or arrays these fields. Contact emails inside such structures are still caught by the generic email sweep; only revenue/tier numbers leak.
  • Over-redaction is possible and safe. Any key containing revenue / tier / email is masked, so a benign revenueNote or tierId is masked too. On egress this is over-redaction, never disclosure.
  • Key-anchored patterns assume serialized-JSON response shape. A value under a differently-worded key, or PII in reformatted prose, is only caught for email (via the generic email sweep). Revenue/tier in free prose is not matched. Non-string content blocks pass through unmodified — verify their shape with the dump-input technique if your gateway emits structured blocks.
  • Egress transformed_payload replaces the response payload wholesale. Verify the rewrite against your gateway version before production, and mind attachment order if other egress transforms run on the same pipeline.

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 linear.egress.redact_customer_data

# Transform-only egress policy: masks customer revenue, tier/segment, and
# contact email in the responses of Linear's Customers read tools
# (getCustomers, getCustomerNeeds, getCustomerTiers) before the response reaches
# the agent. Never denies — a legitimate customer lookup still succeeds, just
# with those fields masked and the record structure (IDs, names, need text)
# intact. No identity exemption: every caller receives the redacted view.
default allow := true

# -----------------------------------------------------------------------------
# Egress scope. Match the post-invoke/output path on EITHER mode or action. If
# we keyed on input.mode alone and a gateway build left it unset, the scope
# check would silently fail and redaction would no-op (fail open, leaking
# revenue/PII). Ingress (tool_pre_invoke / mode "input") satisfies neither
# branch.
# -----------------------------------------------------------------------------
is_egress if { input.mode == "output" }

is_egress if { input.action == "tool_post_invoke" }

# The tool name is exposed on egress under resource.name (PARC),
# tool_metadata.name (legacy), and payload.name (tool-hook canonical). Collect
# all three (lower-cased) and match if ANY carries a Customers-read suffix, so a
# gateway that populates a different surface can't slip a read past the scanner.
# object.get chains keep a missing surface from failing the rule.
candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", ""))

candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", ""))

candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", ""))

# Customers read channels. Verified from tacticlaunch/mcp-linear TOOLS.md
# (linear_ + camelCase). The gateway prepends its configured server-name prefix,
# so we match by suffix, case-insensitively. The official Linear server's
# customer-read tool names are unverified (landscape note) and are NOT pinned
# here — add them once confirmed via a live tools/list.
customer_read_suffixes := {
	"getcustomers",
	"getcustomerneeds",
	"getcustomertiers",
}

is_customer_read_tool if {
	is_egress
	some suffix in customer_read_suffixes
	some n in candidate_names
	endswith(n, suffix)
}

# -----------------------------------------------------------------------------
# Redaction steps. Linear MCP tools return serialized JSON in the response
# content blocks, so the field rewrites are anchored to JSON keys and replace
# only the value (the ${1} capture keeps the key), leaving the surrounding JSON
# valid and parseable. Each step is total over strings: it returns its input
# unchanged when its pattern doesn't apply, so the steps chain safely.
# -----------------------------------------------------------------------------

# `"...revenue...": <value>` — any key containing `revenue` (case-insensitive,
# so `annualRevenue`/`revenue`/`annual-revenue` are covered; the key char class
# allows `_` and `-`). Masks an object value first (a `{amount,currency,…}` money
# object is replaced whole — otherwise the inner amount would leak). The object
# pattern tolerates ONE level of nested braces (`{"amount":{"value":…}}`), so a
# nested money object is caught too; objects nested two or more levels deep are a
# documented residual. Then a quoted-string value, then a bare numeric value
# including an optional exponent (`1.5e6`) so the whole number is consumed and the
# surrounding JSON stays valid. null carries no data and is left alone.
redact_revenue(t) := out if {
	o := regex.replace(t, `(?i)("[a-z0-9_-]*revenue[a-z0-9_-]*"\s*:\s*)\{(?:[^{}]|\{[^{}]*\})*\}`, `${1}"[REDACTED-REVENUE]"`)
	s := regex.replace(o, `(?i)("[a-z0-9_-]*revenue[a-z0-9_-]*"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-REVENUE]"`)
	out := regex.replace(s, `(?i)("[a-z0-9_-]*revenue[a-z0-9_-]*"\s*:\s*)-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`, `${1}"[REDACTED-REVENUE]"`)
}

# `"...tier...": <value>` — any key containing `tier` (case-insensitive, so
# `customerTier`/`tierName`/`customer-tier` are covered; the key char class allows
# `_` and `-`, matching the revenue/email branches). Masks an object value first
# (so a `{name,level,...}` tier object is replaced whole), tolerating ONE level of
# nested braces like the revenue branch, then a quoted string, then a bare number.
# A tier object nested two or more levels deep is a documented residual.
redact_tier(t) := out if {
	o := regex.replace(t, `(?i)("[a-z0-9_-]*tier[a-z0-9_-]*"\s*:\s*)\{(?:[^{}]|\{[^{}]*\})*\}`, `${1}"[REDACTED-TIER]"`)
	s := regex.replace(o, `(?i)("[a-z0-9_-]*tier[a-z0-9_-]*"\s*:\s*)"[^"]*"`, `${1}"[REDACTED-TIER]"`)
	out := regex.replace(s, `(?i)("[a-z0-9_-]*tier[a-z0-9_-]*"\s*:\s*)-?\d+(?:\.\d+)?`, `${1}"[REDACTED-TIER]"`)
}

# `"...email...": "..."` — any key containing `email` (case-insensitive, so
# `contactEmail`/`email`/`contact-email` are covered; the key char class allows
# `_` and `-`). String value only; other email-bearing text is caught by the
# generic sweep below.
redact_email_field(t) := regex.replace(
	t,
	`(?i)("[a-z0-9_-]*email[a-z0-9_-]*"\s*:\s*)"[^"]*"`,
	`${1}"[REDACTED-EMAIL]"`,
)

# Bare email addresses anywhere in the text (word-boundary anchored: local part,
# "@", domain, TLD of at least two letters) — catches contact emails embedded in
# prose (e.g. a customer-need description string) outside an "email" key.
redact_email_text(t) := regex.replace(
	t,
	`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`,
	"[REDACTED-EMAIL]",
)

# Order: the key-anchored field rewrites first (their tokens contain no "@",
# braces, or quoted digits, so no later step can re-match an emitted token),
# then the generic email sweep over whatever text remains.
redact_block(b) := redact_email_text(
	redact_email_field(redact_tier(redact_revenue(b))),
) if {
	is_string(b)
}

# Non-string content blocks (structured blocks) pass through unmodified.
redact_block(b) := b if { not is_string(b) }

# -----------------------------------------------------------------------------
# Transform — emitted only when in scope and at least one block actually
# changed. Otherwise the rule is undefined and the aggregator skips this policy,
# returning the response byte-identical.
# -----------------------------------------------------------------------------

response_payload := object.get(input, "payload", {})

text_blocks := object.get(response_payload, "text", [])

redacted_blocks := [out |
	some block in text_blocks
	out := redact_block(block)
]

transform := {
	"transformed_payload": object.union(response_payload, {"text": redacted_blocks}),
} if {
	is_customer_read_tool
	is_array(text_blocks)
	redacted_blocks != text_blocks
}

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