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 anot is_exemptguard to thetransformrule that readsinput.subject.claims.groupsviaobject.getchains (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, oremail(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_emailis covered;arr/mrr/segmentare 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 intocustomer_read_suffixesonce confirmed via a livetools/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/emailis masked, so a benignrevenueNoteortierIdis 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_payloadreplaces 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)
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