--- name: "Glean: Gate Memory Writes (Read-Only Default)" tags: - glean - gate-memory-writes - role-gate-writes - memory - access-control - least-privilege - ingress - soc2 publishedAt: 2026-07-12 description: | # glean / gate-memory-writes **Direction:** ingress (`tool_pre_invoke`) **Default:** deny memory writes unless the caller is in the pilot group; allow memory reads and every other tool **Package:** `glean.ingress.gate_memory_writes` ## What it does Gates mutating calls to Glean's long-term **memory** surface — the built-in tool exposed as `memory` (and as `read_memory` in Glean's own client guide). A call to that tool is denied when its `action` argument is anything other than `"read"` — i.e. `add`, `update`, or `delete`, or a missing/empty action — **unless** the caller's IdP `groups` claim contains the placeholder pilot group `glean-memory-pilot`. Memory reads (`action:"read"`) always pass, and every other Glean tool (`search`, `chat`, `read_document`, `employee_search`, `memory_schema`, the `knowledge_graph_*` introspection tools, etc.) passes untouched. Memory is Glean's only built-in write and a cross-session persistence channel. Since the March 2026 release, any connected MCP host can add, update, or delete a user's memories. A prompt-injected agent could plant durable instructions (`category:"ConstraintsAndGuardrails"`) that survive across sessions, or erase a user's context with `action:"delete"`. Gating writes at ingress blocks memory-poisoning and unauthorized deletion from connected hosts while leaving retrieval-only use unaffected. The check runs at ingress, before the call reaches the Glean MCP server, so a denied memory write never executes and has no persistent side effect. ## Compliance alignment This policy is the Glean instance of policy family **PF-12 (role-gate-writes)** on the memory surface. It supports alignment with the following controls on the MCP path: - **SOC 2 CC6.1** — supports logical access security over protected assets: Glean memory cannot be mutated over the agent channel without an explicit role grant. **CC6.3** — supports role-based access and least privilege; write capability is bound to a live IdP group, so removing the group in the IdP removes memory-write access on the next call. - **HIPAA §164.308(a)(4)** — supports information access management: memory-write authorization is role-scoped. **§164.312(a)(1)/(a)(2)(i)** — supports technical access control and unique user identification, using the per-call identity from the caller's JWT. - **GDPR Art. 25** — supports data protection by design/default on the agent channel: the default posture is read-only. **Art. 29 / 32(4)** — supports processing only on the controller's instructions; an unauthorized principal cannot alter or delete stored personal context through the agent. ## Tool name matching The DTwo gateway prefixes tool names with the configured MCP server name (e.g. `glean-default-memory`), and that prefix is not standardized — so matching is case-insensitive and by **suffix**: - Admin docs name the tool `memory`; Glean's own client guide surfaces it as `read_memory`. Both names end in `memory`, so a single `endswith(..., "memory")` match covers both suffixes. - The read-only introspection tools `memory_schema`, `knowledge_graph_query`, and `knowledge_graph_schema` do **not** end in `memory`, so they are never matched by this policy. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. Glean's remote server uses generic snake_case names, so if you attach this policy to a pipeline that also fronts a *different* MCP server that happens to expose a tool whose name ends in `memory`, that tool would also be gated — attach it to a Glean-scoped pipeline, or narrow the match. ## Argument shape The decision reads the action from `object.get(input.payload.args, "action", "")` and the caller's identity from `input.subject.claims.groups`. The comparison against `"read"` is exact and case-sensitive to match Glean's `action` enum (`read` | `add` | `update` | `delete`): any other value — including a wrong-case `"READ"`, an unknown verb, or a missing/empty action — is treated as a **write** and denied for non-pilot callers (fail closed). No other argument (`category`, `query`, `read_filters`, ...) is inspected, so the gate cannot be bypassed by unusual argument nesting or encodings. ## Identity Group membership is read fail-closed from `object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])` — i.e. the `groups` claim inside `object.get(input.subject, "claims", {})`. A missing subject, missing claims, a missing `groups` claim, or a `groups` claim that is not an array all mean "not a pilot", and the memory write is denied. Memory reads and non-memory tools are unaffected by identity. ## Examples ### Allowed — memory read, no pilot group required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-default-memory", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["engineering"] } }, "payload": { "name": "glean-default-memory", "args": { "action": "read", "query": "my active projects" } } } } ``` `allow = true`, no reason. ### Allowed — memory write by a pilot-group member ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-default-memory", "type": "tool" }, "subject": { "sub": "auth0|alice", "claims": { "groups": ["glean-memory-pilot"] } }, "payload": { "name": "glean-default-memory", "args": { "action": "add", "category": "ActiveProjects", "content": "Q3 launch" } } } } ``` `allow = true`, no reason. ### Denied — memory write by a non-pilot caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "glean-default-read_memory", "type": "tool" }, "subject": { "sub": "auth0|bob", "claims": { "groups": ["engineering"] } }, "payload": { "name": "glean-default-read_memory", "args": { "action": "delete", "memory_id": "m-123" } } } } ``` `allow = false`, `reason = "Glean memory writes (add, update, delete) are restricted to members of the 'glean-memory-pilot' group ..."`. ## Composition This policy gates *who* may write to Glean memory; it does not inspect *what* is written. Useful companions: - **`apps/glean/default-deny-unknown-tools`** — Glean's tool inventory is admin-mutable (agents-as-tools, gateway-proxied writes). Pair this with a default-deny allowlist so new write surfaces do not appear unreviewed. - **`apps/glean/fence-datasource-scope`** and an egress PII-redaction policy on `search` / `read_document` / `chat` responses, since this policy leaves Glean's (extensive) read path open. - A `meeting_lookup` transcript guard for the other high-sensitivity Glean surface. ## Known limitations - **Pilot group name is a placeholder** — replace `glean-memory-pilot` with your tenant's IdP group name at import time. The policy expects `groups` to be an array claim in the caller's JWT; if your IdP emits groups under a different or namespaced claim (e.g. `https://acme.com/groups`), update `caller_groups` in `policy.md`. - **`args` vs `arguments` key is unverified.** This policy reads the action from `input.payload.args.action`, per the DTwo gateway's documented payload shape. Glean's own tool reference describes the parameter under `arguments`. If your gateway forwards Glean's arguments under a different key, `action` reads as empty and *every* memory call fails closed (denied for non-pilot callers) — verify the live payload shape with the dump-input technique before production and adjust the `object.get` path if needed. - **Reads are open to everyone**, including reads of memories that hold sensitive context. This policy protects integrity/persistence (writes), not confidentiality of the read path — pair with an egress redaction policy if memory contents are sensitive in your tenant. - **Suffix over-match on shared pipelines.** Because matching is by the `memory` suffix, a non-Glean tool on the same pipeline whose name ends in `memory` would also be gated. Attach to a Glean-scoped pipeline or narrow the match. - **Suffix match ignores hook type.** The gate keys on the tool-name suffix alone, so a *prompt* or *resource* whose name ends in `memory` (e.g. a `prompt_pre_fetch` for `session_memory`) is gated as if it were the memory write tool and denied for non-pilot callers. This is a benign, fail-*closed* over-block — a prompt/resource fetch cannot mutate memory, so nothing leaks; the only cost is a confusing memory-write denial on an unrelated hook. It is deliberate: keying on the suffix alone means a missing or unexpected `resource.type`/`action` field can never cause a real memory write to slip through un-gated (fail open). If the confusing message matters in your tenant, add a `resource.type == "tool"` guard — but only after confirming your gateway reliably populates `resource.type`, since gating on it makes a missing value fail open. > **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: ingress apps: - glean industries: [] bundles: - soc2 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package glean.ingress.gate_memory_writes # Deny-by-default: memory reads and every non-memory tool are explicitly allowed # below; every memory *write* requires membership in the pilot group. default allow := false # Placeholder IdP group permitted to perform Glean memory writes. # Replace "glean-memory-pilot" with your tenant's IdP group name at import time. pilot_group := "glean-memory-pilot" # Lowercased tool name. The gateway prefixes tool names with the configured MCP # server name (e.g. `glean-default-memory`), so matching is case-insensitive and # by suffix to stay portable across server-name prefixes. tool_name := lower(object.get(object.get(input, "resource", {}), "name", "")) # Glean's long-term memory surface. Admin docs list it as `memory`; Glean's own # client guide surfaces it as `read_memory`. Both names end in `memory`, so a # single suffix match covers both. The read-only introspection tools # (`memory_schema`, `knowledge_graph_*`) do not end in `memory` and are unaffected. is_memory_tool if { endswith(tool_name, "memory") } # The memory tool's `action` argument: "read" | "add" | "update" | "delete". # Read via object.get so a missing args object or missing action key yields "". memory_action := object.get(object.get(object.get(input, "payload", {}), "args", {}), "action", "") # Only the exact value "read" is a read. Case-sensitive to match Glean's enum: # any other value (add/update/delete/unknown/empty) is treated as a write, so a # missing or empty action fails closed. is_read if { memory_action == "read" } # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim that # is not an array all yield "not a pilot" — memory writes then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # The is_array guard is load-bearing: `some g in caller_groups` iterates the # *values* of an object, so a groups claim shaped as {"role":"glean-memory-pilot"} # would otherwise match and fail OPEN. Requiring an array keeps every non-array # shape (object, string, number) fail-closed. caller_is_pilot if { is_array(caller_groups) some g in caller_groups g == pilot_group } # --- Decision --- # Any tool that is not the memory surface passes untouched. allow if { not is_memory_tool } # Memory reads always pass — retrieval-only use is unaffected. allow if { is_memory_tool is_read } # Memory writes pass only for members of the pilot group. allow if { is_memory_tool not is_read caller_is_pilot } reasons contains msg if { is_memory_tool not is_read not caller_is_pilot msg := sprintf("Glean memory writes (add, update, delete) are restricted to members of the '%s' group — this account can read Glean memory but not modify it through the gateway. Ask your identity admin to add you to '%s', or use action \"read\" for retrieval only. If this was meant to be a read, confirm the call sends action \"read\"; contact your InfoSec team if the policy needs adjusting.", [pilot_group, pilot_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```