--- name: "Linear: Redact Customer Revenue and Contacts" tags: - linear - redact-pii - pii - dlp - redaction - egress - gdpr-ccpa - soc2 publishedAt: 2026-07-12 description: | # linear / redact-customer-pii-egress **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](https://github.com/tacticlaunch/mcp-linear/blob/main/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) ```jsonc { "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. direction: egress apps: - linear industries: [] bundles: - gdpr-ccpa - soc2 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```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...": ` — 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...": ` — 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 } ```