dtwo Policy Store

Box: Role-Gated Writes (Read-Only Default)

Makes Box read-only by default on the MCP path.

Direction
ingress
Rego package
box.ingress.role_gate_writes
App
box
Bundles
soc2gdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:402fdf79b6e7b9fd5df7fe444218b926ed74c5d7c7748b0b41c33c0df2be5d1a

boxrole-gate-writesaccess-controlleast-privilegeingresssoc2gdpr-ccpa

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny writes unless the caller is in the writer group; allow reads for everyone Package: box.ingress.role_gate_writes

What it does

Makes Box read-only by default on the MCP path. Every mutating Box tool — uploads, folder creation, copies, moves, renames, metadata and property updates, comments, hub and docgen writes, collaboration grants, shared links, locks, retention dates, and deletes — is denied unless the caller's IdP groups claim contains the placeholder group box-writers. Read and search tools (who_am_i, get_file_content, get_file_details, list_*, search_*, ai_qa_*, ai_extract_*, community box_file_info_tool, box_search_tool, and the other read-only tools of both server dialects) pass for everyone.

Unknown tools whose names look mutating (any create/update/set/add/upload/move/ copy/delete/remove/rename/lock/unlock/clear verb segment) are also gated, so new upstream write tools fail closed instead of slipping through until someone classifies them.

The check runs at ingress, before the call reaches the Box MCP server, so a denied write never executes and has no side effects.

Compliance alignment

  • SOC 2 CC6.1 — supports logical access security over protected assets: Box content cannot be mutated over the agent channel without an explicit role grant. CC6.3 — supports role-based access and least privilege: write capability is tied to a live IdP group, and removing the group in the IdP removes write access on the next call.
  • HIPAA §164.308(a)(4) — supports information access management for Box tenants holding ePHI: write authorization is role-scoped. §164.312(a)(1) — supports technical access control with 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 — supports processing only on the controller's instructions: unauthorized principals cannot alter personal data in Box through the agent.

Tool name matching

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. box-remote-upload_file), and the prefix is not standardized — so all matching is case-insensitive and by suffix or name segment, covering both Box MCP dialects:

  1. Official remote server (mcp.box.com), verified suffixes: upload_file, upload_file_version, get_upload_url, create_folder, copy_file, copy_folder, move_file, move_folder, update_file_properties, update_folder_properties, set_file_metadata, set_folder_metadata, create_metadata_template, update_metadata_template, create_file_comment, create_hub, copy_hub, update_hub, add_items_to_hub, create_docgen_template, create_docgen_batch, plus the sharing tools create_collaboration, update_collaboration, add_file_shared_link, add_folder_shared_link.
  2. Community server (box-community/mcp-server-box), verified suffixes: box_file_upload_tool, box_file_copy_tool, box_file_move_tool, box_file_rename_tool, box_file_delete_tool, box_file_lock_tool, box_file_unlock_tool, box_file_retention_date_set_tool, box_file_retention_date_clear_tool, box_file_set_download_open_tool, box_folder_create_tool, box_folder_move_tool, box_folder_delete_tool, box_folder_set_collaboration_tool, box_folder_set_upload_email_tool.
  3. Community collaboration stem: any tool name containing box_collaboration_ is treated as a write — the collaboration-create variants (box_collaboration_file_user_by_user_login_tool and its by-id/group/folder siblings) carry no mutating verb in their names, so the whole stem is gated. The official read list_item_collaborations does not contain this stem and stays allowed.
  4. Mutating-verb net: any remaining tool whose hyphen/underscore-separated name segments include a mutation verb (create, update, set, add, upload, move, copy, delete, remove, rename, lock, unlock, clear) is gated. This is what catches the community shared-link writes (box_shared_link_*_create_or_update_tool, box_shared_link_*_remove_tool) while their _get_/_find_by_shared_link_url_ read variants pass, and what keeps future upstream write tools fail-closed.

Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production; if your Box server exposes a write tool whose name carries none of the verbs above, add it to the suffix lists in policy.md.

Argument shape

The decision uses only the tool name (input.resource.name) and the caller's identity (input.subject.claims.groups). Tool arguments are not inspected, so the policy cannot be bypassed by unusual argument keys, nesting, or encodings — and it works identically whether or not a tool's argument schema is documented.

Identity

Group membership is read fail-closed via object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []): a missing subject, missing claims, a missing groups claim, or a groups claim that is not an array all mean "not a writer", and every write is denied. Reads are unaffected by identity.

Examples

Allowed — read tool, no identity required

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "box-remote-get_file_content", "type": "tool" },
    "payload": { "name": "box-remote-get_file_content", "args": { "file_id": "12345" } }
  }
}

allow = true, no reason.

Denied — write tool, caller not in the writer group

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "box-remote-upload_file", "type": "tool" },
    "subject": { "sub": "auth0|alice", "claims": { "groups": ["engineering"] } },
    "payload": {
      "name": "box-remote-upload_file",
      "args": { "parent_folder_id": "0", "name": "report.pdf" }
    }
  }
}

allow = false, reason = "Box write tools are restricted to members of the 'box-writers' group ...".

Composition

This policy is the Box least-privilege baseline; it gates who may write, not what they may write. Useful companions:

  • An external-sharing guard that inspects create_collaboration / add_*_shared_link arguments (collaborator domain, access: open) so that even authorized writers cannot share content outside the organization.
  • A destructive-op gate that keeps the community server's box_file_delete_tool / box_folder_delete_tool (especially recursive: true) behind a stricter admin group than ordinary writes.
  • An egress PII/PHI redaction policy on get_file_content, ai_qa_*, ai_extract_*, and search responses, since this policy leaves the read path open.

Known limitations

  • Group names are placeholders — replace box-writers with your IdP's group name at import time. The policy expects groups to be an array claim in the caller's JWT; if your IdP emits roles under a different or namespaced claim (e.g. https://acme.com/groups), update caller_groups in policy.md.
  • Reads are open to everyone, including Box AI tools (ai_qa_*, ai_extract_*) that send file content through Box AI, and content egress tools like get_file_content. Pair with a read fence and/or egress redaction if your Box tenant holds regulated content.
  • Verb-net over-matching on shared pipelines. The mutating-verb net inspects every tool name on the pipeline, so non-Box tools with mutating-looking names (including management tools such as dtwo-create-policy) are gated too when this policy is attached to a pipeline that fronts more than the Box server. Attach it to a Box-scoped pipeline, or add an explicit passthrough allow if rule for your management prefix.
  • Verb-net under-matching. A write tool whose name carries none of the listed verbs and is not on a verified list slips through as a "read". Known candidates: the community server's tag tools, whose exact names the landscape research could not verify — verify with a live tools/list and add them to the suffix lists if present in your deployment.
  • Unverified community names. The folder variants of the community collaboration-create tools are gated via the box_collaboration_ stem because their exact names are unverified; the community tag-tool names are likewise unverified (see above).
  • A server named with a mutating verb (e.g. an MCP server configured as box-uploads) would make every one of its tools match the verb net and require the writer group — a fail-closed false positive; rename the server or add a passthrough.

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 box.ingress.role_gate_writes

# Deny-by-default: reads are explicitly allowed below; every write requires
# membership in the writer group.
default allow := false

# Placeholder IdP group permitted to perform Box writes.
# Replace "box-writers" with your IdP's group name at import time.
writer_group := "box-writers"

# Lowercased tool name. The gateway prefixes tool names with the configured
# MCP server name (e.g. `box-remote-upload_file`), so matching below is
# case-insensitive and suffix/segment based to stay portable.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

# Raw (un-lowercased) name, kept so camelCase tool names can be split on their
# case boundaries by the mutating-verb net below.
raw_tool_name := object.get(object.get(input, "resource", {}), "name", "")

# Separator-normalized name: every run of non-alphanumerics collapses to a
# single `_`, so stem matching fires whether a server uses `_` or `-`.
normalized_tool_name := lower(regex.replace(raw_tool_name, `[^A-Za-z0-9]+`, "_"))

# --- Identity (fail closed) ---
# Missing subject, missing claims, a missing groups claim, or a groups claim
# that is not an array all yield "not a writer" — writes then deny.
caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])

# `is_array` guard is load-bearing: `some group in caller_groups` iterates the
# *values* of an object, so a groups claim shaped as `{"x": "box-writers"}`
# would otherwise match and fail OPEN. Requiring an array keeps every non-array
# shape (object, string, number) fail-closed, as the Identity section promises.
caller_is_writer if {
    is_array(caller_groups)
    some group in caller_groups
    group == writer_group
}

# --- Write-tool detection ---

# Verified write tools on the official remote server (mcp.box.com), matched by
# suffix so any gateway server-name prefix still matches.
official_write_suffixes := [
    "upload_file",
    "upload_file_version",
    "get_upload_url",
    "create_folder",
    "copy_file",
    "copy_folder",
    "move_file",
    "move_folder",
    "update_file_properties",
    "update_folder_properties",
    "set_file_metadata",
    "set_folder_metadata",
    "create_metadata_template",
    "update_metadata_template",
    "create_file_comment",
    "create_hub",
    "copy_hub",
    "update_hub",
    "add_items_to_hub",
    "create_docgen_template",
    "create_docgen_batch",
    "create_collaboration",
    "update_collaboration",
    "add_file_shared_link",
    "add_folder_shared_link",
]

# Verified write tools on the community server (box-community/mcp-server-box).
community_write_suffixes := [
    "box_file_upload_tool",
    "box_file_copy_tool",
    "box_file_move_tool",
    "box_file_rename_tool",
    "box_file_delete_tool",
    "box_file_lock_tool",
    "box_file_unlock_tool",
    "box_file_retention_date_set_tool",
    "box_file_retention_date_clear_tool",
    "box_file_set_download_open_tool",
    "box_folder_create_tool",
    "box_folder_move_tool",
    "box_folder_delete_tool",
    "box_folder_set_collaboration_tool",
    "box_folder_set_upload_email_tool",
]

is_write_tool if {
    some suffix in official_write_suffixes
    endswith(tool_name, suffix)
}

is_write_tool if {
    some suffix in community_write_suffixes
    endswith(tool_name, suffix)
}

# Community collaboration tools (grant/update/delete collaborations) share the
# `box_collaboration_` stem, and the create variants carry no mutating verb in
# their names, so the whole stem is gated. The official read
# `list_item_collaborations` does not contain this stem and stays allowed.
is_write_tool if {
    contains(normalized_tool_name, "box_collaboration_")
}

# Fail-closed net for unknown mutating-looking tools: if any name segment is a
# mutation verb, treat the tool as a write so new upstream write tools are
# gated before anyone classifies them. Also catches the community shared-link
# create/update/remove tools, while their get/find read variants pass.
mutating_verbs := {
    "create", "update", "set", "add", "upload",
    "move", "copy", "delete", "remove", "rename",
    "lock", "unlock", "clear",
}

# Split the tool name into segments. First insert a boundary at every
# lowercase/digit -> uppercase transition so camelCase names (`uploadFile`)
# split into verb segments (`upload`, `file`); snake_case and ALL-CAPS names
# are unaffected. Then lowercase and split on any run of non-alphanumeric
# characters (covers both the gateway's `-` prefixing and Box's `_` naming).
name_segments := {segment |
    some segment in regex.split(`[^a-z0-9]+`, lower(regex.replace(raw_tool_name, `([a-z0-9])([A-Z])`, `$1 $2`)))
    segment != ""
}

is_write_tool if {
    some segment in name_segments
    mutating_verbs[segment]
}

# --- Decision ---

# Reads (and anything that is not a verified or mutating-looking write) pass
# for everyone.
allow if {
    not is_write_tool
}

# Writes pass only for members of the writer group.
allow if {
    is_write_tool
    caller_is_writer
}

reasons contains msg if {
    is_write_tool
    not caller_is_writer
    msg := sprintf("Box write tools are restricted to members of the '%s' group — this account has read-only Box access through the gateway. Ask your identity admin to add you to '%s', or hand this step to a teammate with Box write access. If this tool is actually read-only, contact your InfoSec team to update the policy.", [writer_group, writer_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)