dtwo Policy Store

Cap Asana Batch Task Mutations

Caps the blast radius of Asana's official V2 batch write tools. At ingress it:

Direction
ingress
Rego package
asana.ingress.cap_batch_mutation
App
asana
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:d4d1d191072dce9a9d61794abb85e2fbe009749d6c44a2629ff577e7b1d62ede

asanacap-bulk-exportbatch-mutationingresssoc2

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: asana.ingress.cap_batch_mutation

What it does

Caps the blast radius of Asana's official V2 batch write tools. At ingress it:

  1. Denies any create_tasks or update_tasks call whose task array holds more than a configurable ceiling (default 10) of task objects, and
  2. Separately denies any update_tasks batch that sets completed: true on more than the ceiling number of elements (mass-completion guard).

Everything else — reads, single-record community tools, and batch calls within the ceiling — passes through unchanged.

Asana's official V2 batch tools (create_tasks, update_tasks) accept up to 50 task objects per call. A single injected prompt can therefore create, mutate, or mass-complete an entire project's worth of tasks in one invocation. Clamping the array size blunts that blast radius while leaving normal small batches working. The rule counts array elements and completed: true flags rather than assuming one record per call.

Why ingress and not egress

Batch task creation/mutation is a write with permanent side effects — once the call reaches Asana the tasks exist, assignees and followers have been notified, and completions have fired downstream automations. Egress can only mask the response, not undo the writes. Ingress denial is the only point at which the mass-mutation is actually prevented.

Compliance alignment

  • SOC 2 CC6.7 / PI1.5 — supports restricting the movement/removal of information and the integrity of stored records by capping how many task records a single agent call can create, mutate, or mass-complete, blunting the blast radius of a runaway or injected batch write on the agent channel.
  • GDPR Art. 5(1)(d) (accuracy) — supports the anti-mass-corruption posture by capping how many task records a single agent call can create or mutate, limiting the damage of a runaway or injected batch write.
  • GDPR Art. 5(1)(c) (data minimisation) — supports proportionate processing by bounding bulk write volume on the agent channel.
  • CCPA/CPRA 11 CCR §7002 (proportionality) — supports processing that is reasonably necessary and proportionate by rejecting oversized bulk mutations.

(Per the coverage matrix, these GDPR/CCPA rows map to policy family PF-08; this is the write-side blast-radius variant of cap-bulk-export.)

Tool name matching

Matching is suffix-based and case-insensitive, for portability across gateway server-name prefixes:

  • *create_tasks — official V2 batch create
  • *update_tasks — official V2 batch update

The tool name is read from both the PARC field (input.resource.name) and the legacy alias (input.payload.name) via object.get chains, and the two are matched independently — a request that omits the resource block, or carries a non-string value in one field, still cannot skip the match (fail-closed hardening: a missing/non-string name resolves to "" rather than leaving the suffix check undefined). Leading/trailing whitespace is stripped with trim_space before matching, so padding the verb with a trailing space, tab, or newline (...create_tasks\n) does not evade the suffix check.

The community singular tools asana_create_task / asana_update_task end in create_task / update_task (no trailing s), so they are not matched — they mutate one record per call and are out of scope. Comment tools, previews, and reads are also unaffected.

The DTwo gateway prefixes tool names with the configured MCP server name (e.g. asana-mcp-create_tasks), and that prefix is not standardized. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

Each batch tool takes an array of up to 50 task objects. Asana does not publish the V2 per-parameter JSON schema on its docs page — per-parameter schemas are only visible via a live tools/list against a connected server — so the exact argument key that holds the array is UNVERIFIED. The policy therefore checks a small ordered list of candidate keys (batch_keys = ["tasks", "data", "items"]) and uses object.get defensively:

  • If a candidate key holds an array, its length (and, for update_tasks, its count of completed: true elements) is checked against the ceiling.
  • If no candidate key holds an array on a batch call, the size cannot be verified and the call is denied (fail closed) rather than allowed through under a renamed key. Confirm the real key for your deployment and put it first in batch_keys.
  • If a candidate key holds an array and another top-level argument holds an array under an unrecognized key, the call is also denied. This closes a decoy-smuggling bypass: without it, a one-element array under tasks would satisfy the recognized-array check while the real oversized batch rode along under an unrecognized key (oversize only inspects the recognized arrays).

Elements that are not objects are skipped by the completed: true count.

Examples

Allowed — batch within the ceiling

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "asana-mcp-create_tasks", "type": "tool" },
    "payload": {
      "name": "asana-mcp-create_tasks",
      "args": { "tasks": [ { "name": "a" }, { "name": "b" } ] }
    }
  }
}

allow = true, no reason.

Allowed — community singular tool (out of scope)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "asana-mcp-asana_create_task", "type": "tool" },
    "payload": { "name": "asana-mcp-asana_create_task", "args": { "name": "one task" } }
  }
}

allow = true — singular tools mutate one record and are never matched.

Denied — oversized batch

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "asana-mcp-create_tasks", "type": "tool" },
    "payload": {
      "name": "asana-mcp-create_tasks",
      "args": { "tasks": [ /* 11 task objects */ ] }
    }
  }
}

allow = false, reason: "This Asana batch call requests 11 task objects, above the 10-task ceiling. …"

Denied — mass completion

An update_tasks batch that marks more than 10 tasks completed: true is denied with both the oversize reason and the bulk-completion reason (a batch with >10 completed elements necessarily has >10 elements).

Denied — unrecognized array key (fail closed)

A batch call whose array sits under a key not in batch_keys is denied because its size cannot be verified.

Denied — decoy array smuggling

A batch call that puts a tiny array under a recognized key (e.g. tasks: [one]) while carrying the real oversized batch under an unrecognized key (e.g. custom: [50 objects]) is denied: the presence of any array under an unrecognized key, alongside a recognized one, fails closed.

Composition

This policy is single-purpose. Useful companions on Asana:

  • A protected-project write fence (deny writes to HR/Legal/M&A project GIDs).
  • A destructive-op freeze (deny *delete_task and community delete tools).
  • An egress PII redaction policy on get_task / search_tasks responses.

Known limitations

  • Unverified argument key. The batch array key is not published by Asana; batch_keys is a best-effort candidate list (tasks, data, items). Confirm the real key via a live tools/list and put it first. Until then, legitimate batch calls whose array sits under a different key are denied by the fail-closed rule — a deliberate trade-off favouring safety over silent bypass.
  • Single ceiling. Because the same ceiling bounds both total elements and completed: true elements, any batch that trips the completion guard also trips the size guard; the completion reason adds specificity for the mass-completion case. Raise ceiling (or split the two limits) if your workspace needs a different balance.
  • Tool-inventory drift. Asana's V2 tool set evolves; if a new batch tool ships with a different suffix, add it to the matching rules.
  • Decoy guard is a superset deny. The decoy-smuggling guard denies any batch call that carries a top-level array under an unrecognized key while a recognized key also holds an array. If a legitimate batch tool genuinely takes a second top-level array argument (not the task array — e.g. a top-level options/ followers list), this rule would deny it as a false positive. Add that key to batch_keys (or split it out) once you confirm the real schema via tools/list. Arrays nested inside task objects (e.g. per-task followers) are not affected — only top-level args keys are scanned.
  • Value-typed completion flag. The mass-completion count matches only a JSON boolean completed: true; a non-boolean truthy value (e.g. the string "true") is not counted by the completion-specific guard. This is not a bypass of the size cap: completing more than ceiling tasks still requires more than ceiling array elements, which the oversize guard denies regardless of the completed value type.
  • No identity-based exemptions. All callers are subject to the same ceiling. Add an allow if branch keyed on input.subject.claims (e.g. a placeholder "asana-admins" group — replace with your IdP's group name at import time) if you need a break-glass path for large legitimate batches.

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 asana.ingress.cap_batch_mutation

# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false

# Maximum task objects permitted in a single official batch call, and the
# maximum number of tasks a single update_tasks call may mark completed.
# Tune this for your workspace; Asana's V2 batch tools accept up to 50.
ceiling := 10

# Candidate argument keys that may hold the batch task array. Asana does NOT
# publish the V2 per-parameter schema on its docs page (schemas are only visible
# via a live `tools/list`), so the exact key is UNVERIFIED. Checked in order;
# confirm the real key for your deployment and put it first. If none of these
# keys holds an array on a batch call, the size cannot be verified and the call
# is denied (fail closed) rather than allowed through under a renamed key.
batch_keys := ["tasks", "data", "items"]

# --- Tool matching (suffix-based, case-insensitive, for portability) ---
# The gateway prefixes tool names with the configured MCP server name, so we
# match on the suffix. Official V2 batch tools are `create_tasks` /
# `update_tasks` (plural). The community singular tools `asana_create_task` /
# `asana_update_task` end in `create_task` / `update_task` (no trailing "s") and
# are NOT matched — they mutate one record per call and are out of scope.
#
# The name is read via object.get chains from BOTH the PARC field
# (input.resource.name) and the legacy alias (input.payload.name), coerced to a
# lowercased, whitespace-trimmed string. A missing OR non-string value resolves
# to "" rather than leaving the rule undefined (an undefined name would make the
# endswith checks undefined and skip matching — a fail-OPEN bypass). trim_space
# strips leading/trailing whitespace so a padded verb (`...create_tasks\n`)
# cannot slip past the suffix check. The two fields are matched independently so
# a malformed value in one cannot suppress a real batch suffix in the other.
name_of(key) := trim_space(lower(v)) if {
	v := object.get(object.get(input, key, {}), "name", "")
	is_string(v)
}

name_of(key) := "" if {
	v := object.get(object.get(input, key, {}), "name", "")
	not is_string(v)
}

resource_name := name_of("resource")

payload_name := name_of("payload")

is_create_batch if { endswith(resource_name, "create_tasks") }

is_create_batch if { endswith(payload_name, "create_tasks") }

is_update_batch if { endswith(resource_name, "update_tasks") }

is_update_batch if { endswith(payload_name, "update_tasks") }

is_batch_tool if { is_create_batch }

is_batch_tool if { is_update_batch }

# --- Batch array discovery ---
# args is read through an object.get chain so a missing payload or args block
# yields {} (never leaves a reference undefined). Every candidate key whose
# value is actually an array is collected; object.get(..., null) means a missing
# key is skipped, not counted as an empty [].
args := object.get(object.get(input, "payload", {}), "args", {})

candidate_arrays contains arr if {
	is_batch_tool
	some key in batch_keys
	arr := object.get(args, key, null)
	is_array(arr)
}

array_recognized if {
	count(candidate_arrays) > 0
}

# Number of elements in a candidate array that set completed: true.
completed_in(arr) := count([t |
	some t in arr
	is_object(t)
	object.get(t, "completed", false) == true
])

# --- Violation conditions ---
# The batch array exceeds the element ceiling (create_tasks or update_tasks).
oversize if {
	some arr in candidate_arrays
	count(arr) > ceiling
}

# An update_tasks batch marks more than `ceiling` tasks completed in one call.
too_many_completed if {
	is_update_batch
	some arr in candidate_arrays
	completed_in(arr) > ceiling
}

# A batch call whose task array we cannot locate under any known key — size
# cannot be verified, so deny (fail closed) rather than let an unbounded batch
# through under a renamed argument.
unrecognized_batch if {
	is_batch_tool
	not array_recognized
}

# Decoy-smuggling guard. A caller could satisfy array_recognized with a tiny
# "decoy" array under a recognized key (e.g. tasks: [one object]) while carrying
# the real, oversized batch under an UNRECOGNIZED key (e.g. custom: [50 objects]).
# oversize only inspects candidate_arrays, so it would miss the smuggled array and
# the call would pass. When a recognized array IS present but there is ALSO a
# top-level array under a key we do not recognize, we cannot be sure we are sizing
# the real batch — so we fail closed. (Gated on array_recognized so the pure
# renamed-key case with no decoy is still reported by unrecognized_batch alone,
# not double-counted here.)
unknown_key_array if {
	is_batch_tool
	array_recognized
	some key, val in args
	is_array(val)
	not key in batch_keys
}

# --- Allow rules ---
# Anything that isn't an official batch tool passes untouched (this includes the
# community singular create/update tools and all read tools).
allow if {
	not is_batch_tool
}

# A batch call passes when we can see its array and it is within both ceilings,
# and there is no array smuggled under an unrecognized key (decoy guard).
allow if {
	is_batch_tool
	array_recognized
	not oversize
	not too_many_completed
	not unknown_key_array
}

# --- Deny reasons ---
reasons contains msg if {
	oversize
	n := max([count(arr) | some arr in candidate_arrays])
	msg := sprintf("This Asana batch call requests %d task objects, above the %d-task ceiling. Split it into smaller batches of %d or fewer tasks per call. If a larger batch is genuinely required, ask your workspace admin to raise the ceiling.", [n, ceiling, ceiling])
}

reasons contains msg if {
	too_many_completed
	m := max([completed_in(arr) | some arr in candidate_arrays])
	msg := sprintf("This update_tasks call marks %d tasks completed in a single call, above the %d-task bulk-completion ceiling. Complete tasks in smaller batches, or ask your workspace admin to raise the ceiling.", [m, ceiling])
}

reasons contains msg if {
	unrecognized_batch
	msg := sprintf("This Asana batch call did not expose a recognizable task array (checked keys: %s), so its size cannot be verified; it is denied by default. Confirm the batch argument key for your Asana MCP deployment and add it to batch_keys in this policy. Contact your workspace admin if this blocks a legitimate call.", [concat(", ", batch_keys)])
}

reasons contains msg if {
	unknown_key_array
	msg := sprintf("This Asana batch call carries a task array under an unrecognized argument key alongside a recognized one (recognized keys: %s), so its true batch size cannot be verified; it is denied to prevent a small decoy array masking a larger smuggled batch. Put the full task array under a single recognized key, or add the real key to batch_keys in this policy. Contact your workspace admin if this blocks a legitimate call.", [concat(", ", batch_keys)])
}

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)