Fence Roadmap and Initiative Reads (Egress)
Fences the responses of Linear's roadmap, initiative, and strategy read tools.
- Direction
- egress
- Rego package
linear.egress.fence_roadmap- App
- linear
- Bundle
- soc2
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:9b1dd6be93d618079dc1bb1e167f2deafc4e08c2fc0ed3b9593b1d82d47d45e0
linearfence-sensitive-scopesroadmapegresssoc2
What this policy does
Direction: egress (tool_post_invoke)
Default: deny (withhold the response) on a guarded read by an un-privileged caller; allow otherwise
Package: linear.egress.fence_roadmap
What it does
Fences the responses of Linear's roadmap, initiative, and strategy read tools. When one of these tools returns and the caller is not in the product or exec IdP group, the entire response is withheld (denied) before it reaches the agent. Every other tool response — and the same reads for a privileged caller — passes through unchanged.
The guarded reads return unreleased product plans, launch timing, initiative/project narratives, and draft documents. That content is the exact material pre-announcement-leak and MNPI (material non-public information) controls care about, so exposure is gated on IdP-group membership:
linear_getRoadmapslinear_getInitiativeslinear_getInitiativeUpdates- initiative / project-update reads (e.g.
linear_getProjectUpdates, and the Feb-2026 official equivalents) linear_getMilestones— project-milestone reads carry launch/target dates (launch timing)linear_getDocuments(PRDs/specs),linear_searchDocuments(same document content, reached by search), and Linear's document reads (get_document/list_documents)linear_getDocumentContentHistory— leaks edited-out draft content, so it is guarded even though its current text may look benign
Why egress and not ingress
The sensitivity lives in the returned unreleased-plan content, not in the request arguments — a call to linear_getRoadmaps looks identical whether the workspace has one benign roadmap or a quarter of unannounced launches. There is nothing in the request to key an ingress rule on beyond the tool name, and blocking at ingress would also be correct but coarser. This policy blocks at egress so it sits on the actual data path and composes cleanly with an ingress role-gate (defense in depth). Because the whole response is unreleased-plan content, the protective action is to withhold the entire response rather than field-redact it — a partially redacted roadmap is still a roadmap.
Compliance alignment
- SOC 2 C1.1 — supports identifying and protecting confidential information by keeping unreleased roadmap/initiative content off the agent channel for callers outside the entitled groups (PF-23 sensitive-scope fencing).
- SOC 2 CC6.7 / P6.1 — supports restricting the transmission and disclosure of confidential and personal information to the agent by withholding roadmap/initiative/strategy responses from callers outside
product/exec(coverage-matrix §2.1).
All alignment is on the MCP path only (see the compliance note below).
Tool name matching
Linear has three naming schemes for the same actions — official bare snake_case (get_roadmaps), tacticlaunch linear_ + camelCase (linear_getRoadmaps), and community fully-snake (linear_get_roadmaps) — and the gateway further prefixes every tool with the configured MCP server name. The policy therefore:
- collects the tool name from all three egress surfaces that carry it —
input.resource.name(PARC),input.tool_metadata.name(legacy), andinput.payload.name(tool-hook canonical) — lowercased, so a gateway that populates a different surface can't slip a guarded read past the fence (this is a deny policy, so an unrecognized name would otherwise fail open), - strips
_from each so one suffix matches all three spellings, then - matches by suffix (
endswith): a response is guarded if any surface ends in one of these normalized read-tool suffixes:
getroadmaps, getroadmap, listroadmaps, getinitiatives, getinitiative, listinitiatives, getinitiativeupdates, getinitiativeupdate, listinitiativeupdates, getprojectupdates, getprojectupdate, listprojectupdates, getmilestones, getmilestone, listmilestones, getdocuments, getdocument, listdocuments, searchdocuments, getdocumentcontenthistory.
Additionally, tacticlaunch exposes by-id reads as get<Noun>ById (verified pattern: linear_getIssueById). Each surface name is therefore also matched with a trailing byid trimmed, so getRoadmapById / getInitiativeById / getDocumentById / getMilestoneById resolve to the same singular get<noun> suffix and are fenced. getProjectById stays unfenced because it trims to getproject, which is not a guarded suffix (consistent with the intentional non-fence of the general project read).
Suffixes are read-verb-anchored (get/list) forms in both singular by-id and plural collection spellings, so write/create/update tools (createInitiativeUpdate, updateProject) are not matched. The singular get<noun> forms are guarded because Linear's official server names its by-id reads that way (get_issue, get_project, get_document), so the by-id read of a single unannounced initiative/roadmap/milestone/update (get_initiative, get_roadmap, …) is fenced exactly like the collection read. Both get and list variants are included to cover the official reads whose exact names are unverified. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.
Argument shape
This policy reads no tool arguments — the decision is (guarded tool) × (caller group) only. Identity groups are read via object.get(input.subject, "claims", {}) → groups, defaulting to []: a missing subject, missing claims, or missing groups yields no group, so the caller is treated as un-privileged and the response is withheld (fail closed — redaction/denial rather than exposure).
Examples
Allowed — caller is in the product group
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "linear-mcp-linear_getRoadmaps", "type": "tool" },
"payload": {
"name": "linear-mcp-linear_getRoadmaps",
"text": ["[{\"id\":\"road_1\",\"name\":\"H2 launches\"}]"]
},
"subject": { "claims": { "groups": ["product"] } }
}
}
allow = true, no reason.
Denied — initiative read by a caller outside product/exec
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "linear-mcp-linear_getInitiatives", "type": "tool" },
"payload": {
"name": "linear-mcp-linear_getInitiatives",
"text": ["[{\"id\":\"init_1\",\"name\":\"Project Titan (unannounced)\"}]"]
},
"subject": { "claims": { "groups": ["marketing"] } }
}
}
allow = false, roadmap/initiative reason. A response to linear_getIssues or linear_getProjects from the same caller passes through — only roadmap/initiative/strategy reads are fenced.
Composition
This policy is single-purpose (an egress content fence). Useful companions on the Linear connector:
- An ingress role-gate that stops out-of-group callers from invoking the roadmap/initiative reads at all (this egress fence is the backstop if the ingress gate is absent or a new read tool slips through).
- A
default-deny-unknown-toolsallowlist so a newly added/renamed roadmap read is denied until reviewed rather than leaking before this suffix list is updated. - A customer-data redaction egress policy on
*getCustomers/*getCustomerNeeds/*getCustomerTiers.
Known limitations
- Group names are placeholders — replace
productandexecwith your IdP's group names at import time. They are matched exactly againstinput.subject.claims.groups; a case or spelling mismatch withholds the response (fail closed). - Placeholder-claim trust. The gate trusts
input.subject.claims.groupsas asserted by the IdP-issued JWT. If your IdP does not emit agroupsclaim (Auth0, for example, does not without explicit configuration), every caller is treated as un-privileged and every guarded response is withheld until the claim is wired up. Confirm the claim shape withdtwo-list-claims/ the dump-input technique before deployment. A non-arraygroupsvalue (e.g. a bare string) is not iterated and also fails closed. - Unverified official tool names. The Feb-2026 official initiative / project-update read tool names are unverified in the landscape note — confirm them via a live
tools/listand extendguarded_suffixesif they differ. Bothget/listprefixes, both singular by-id (get_initiative) and plural collection (get_initiatives/list_initiatives) spellings, and the tacticlaunchget<Noun>ByIdby-id form (via a trailing-byidtrim) are matched, but a differently-worded read (e.g. an officialroadmap_details- orfetch_initiative-style name that doesn't end in a guardedget<noun>/list<noun>suffix, or asearch-over-plans read like a hypotheticalsearchInitiatives) would slip through until its suffix is added. Re-enumerate after any Linear MCP upgrade. - Sibling read surfaces are out of scope. This policy fences the roadmap/initiative/strategy/document read tools by name; it does not inspect responses of other reads that can incidentally surface the same content. In particular Linear comments attach to initiatives, projects, updates, and documents (
linear_getComments/list_comments), and issue reads (get_issue) can quote roadmap context — those responses are not fenced and pass through for out-of-group callers. Add a companion egress policy on the comment/issue read surface (and the customer-data reads) if that leakage path matters in your tenant. - Search reaches the same content, so
searchDocumentsis fenced too. tacticlaunch'slinear_searchDocumentsreturns document bodies by another path, so its suffix (searchdocuments) is inguarded_suffixesand is withheld for out-of-group callers exactly likegetDocuments. There is no matching document-search tool on the official server in the verified baseline; if your tenant exposes a differently-named search-over-plans read, add its suffix. By contrastgetProjects(a general project list) is intentionally not fenced — only roadmap/initiative/strategy and project/initiative update reads, plus document reads, are. - Whole-response denial, not field redaction. Because the sensitivity is the entire returned plan, a guarded response is withheld in full; this policy does not attempt to return a partial/redacted roadmap. If you need field-level redaction instead, replace the deny with a
transformon the same tools. - Egress only. This blocks the response; it does not stop the underlying read from executing against Linear (a read has no side effects, so this is acceptable). Pair with an ingress gate if you also want to avoid the upstream call.
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.fence_roadmap
# Deny-by-default: an egress response is withheld unless an allow rule fires.
# For a guarded roadmap/initiative/strategy read this means the response is
# blocked (denied) unless the caller is in a privileged group.
default allow := false
# Placeholder IdP group names — map these to your tenant's IdP groups at import.
privileged_groups := {"product", "exec"}
# --- Tool identification -------------------------------------------------
# The tool name is exposed on egress under three surfaces that carry the same
# value: resource.name (PARC), tool_metadata.name (legacy), and payload.name
# (tool-hook canonical). We collect all three because this is a DENY policy: if
# a guarded read's name only appeared on a surface we didn't inspect, the tool
# would go unrecognized and the response would fail OPEN (leak). Each name is
# lowercased and has its underscores stripped, so one suffix matches all three
# Linear naming schemes — official snake_case (get_roadmaps / list_documents),
# tacticlaunch camelCase (linear_getRoadmaps), and fully-snake community forms.
# The gateway server-name prefix separator (hyphen) is left intact; the guarded
# suffixes contain no hyphens. object.get chains keep a missing surface from
# failing the rule.
candidate_names contains replace(lower(object.get(object.get(input, "resource", {}), "name", "")), "_", "")
candidate_names contains replace(lower(object.get(object.get(input, "tool_metadata", {}), "name", "")), "_", "")
candidate_names contains replace(lower(object.get(object.get(input, "payload", {}), "name", "")), "_", "")
# tacticlaunch also exposes by-id reads as get<Noun>ById (verified pattern:
# linear_getIssueById). A by-id read of a guarded noun — getRoadmapById /
# getInitiativeById / getDocumentById / getMilestoneById — would otherwise end in
# "byid" and match none of the get<noun> suffixes, failing OPEN (leaking a single
# unannounced initiative/roadmap/milestone/document). We therefore also add a
# copy of each surface name with a trailing "byid" trimmed, so get<GuardedNoun>ById
# resolves to the same singular get<noun> suffix. get_project stays unfenced
# because getprojectbyid trims to getproject, which is not a guarded suffix.
candidate_names contains trim_suffix(replace(lower(object.get(object.get(input, "resource", {}), "name", "")), "_", ""), "byid")
candidate_names contains trim_suffix(replace(lower(object.get(object.get(input, "tool_metadata", {}), "name", "")), "_", ""), "byid")
candidate_names contains trim_suffix(replace(lower(object.get(object.get(input, "payload", {}), "name", "")), "_", ""), "byid")
# Normalized suffixes of the roadmap/initiative/strategy READ tools whose
# responses carry unreleased-plan content. Read verbs (get/list) with both
# singular by-id and plural collection nouns (Linear's official server names
# by-id reads get_<noun> singular — get_issue/get_project/get_document — so the
# singular get forms are guarded alongside the plural). Write verbs are on
# create/update, so createInitiativeUpdate / updateProject are not matched (they
# end in ...ateinitiativeupdate / ...ateproject, never get<noun>). Both get- and
# list- forms are included to cover the unverified Feb-2026 official read names.
guarded_suffixes := {
"getroadmaps",
"getroadmap",
"listroadmaps",
"getinitiatives",
"getinitiative",
"listinitiatives",
"getinitiativeupdates",
"getinitiativeupdate",
"listinitiativeupdates",
"getprojectupdates",
"getprojectupdate",
"listprojectupdates",
"getmilestones",
"getmilestone",
"listmilestones",
"getdocuments",
"getdocument",
"listdocuments",
"searchdocuments",
"getdocumentcontenthistory",
}
is_guarded_tool if {
some suffix in guarded_suffixes
some n in candidate_names
endswith(n, suffix)
}
# --- Identity ------------------------------------------------------------
# Caller's IdP groups; a missing subject/claims/groups yields [] so a caller
# with no usable claim is treated as un-privileged (fail closed → response
# withheld rather than exposed).
caller_groups := groups if {
claims := object.get(object.get(input, "subject", {}), "claims", {})
groups := object.get(claims, "groups", [])
}
# The caller is in one of the privileged groups.
caller_in_privileged_group if {
some g in caller_groups
privileged_groups[g]
}
# --- Allow rules ---------------------------------------------------------
# Any response that isn't a guarded roadmap/initiative/strategy read passes.
allow if not is_guarded_tool
# Guarded reads pass only for callers in a privileged group.
allow if {
is_guarded_tool
caller_in_privileged_group
}
# --- Deny reason ---------------------------------------------------------
reasons contains "This Linear response contains roadmap, initiative, or strategy content (unreleased plans and launch timing that may be material non-public information). It is restricted to the product and exec groups (placeholder IdP group names \"product\" and \"exec\"). Ask your administrator to grant you the appropriate group if you need pre-announcement access, and confirm your IdP is emitting the `groups` claim. If you believe you already have this access, ask your admin to verify your IdP group mapping." if {
is_guarded_tool
not caller_in_privileged_group
}
reason := joined if {
count(reasons) > 0
reason_list := sort([r | some r in reasons])
joined := concat("; ", reason_list)
} 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