title: MCP Tool Returns Untrusted External Content Carrying Hidden Agent Instructions Without Spotlighting
id: ATR-2026-02403
rule_version: 2
status: experimental
description: |
  Detects the confused-deputy shape in which an MCP server hands third-party authored content back to the model as a RAW tool result, with no spotlighting / delimiting wrapper, while that content carries instructions hidden from the human reviewer. The canonical instance is the Microsoft Azure DevOps MCP server (disclosed by Manifold Security, reported 2026-07-22): wiki and build-log tools route their output through a shared createExternalContentResponse helper that applies nonce-delimited spotlighting, but repo_get_pull_request_by_id never calls it and returns the pull-request description verbatim. An attacker puts an HTML comment in a PR description — invisible in the web UI, but returned literally by the REST API — and the reviewing agent executes it under the reviewer's own credentials, reaching projects the attacker cannot reach and writing the stolen content back into a PR comment. This rule is deliberately vendor-neutral: the detectable object is the tool RESPONSE payload, not the vendor. Layer 1 anchors on a forge external-content field (description / body / comment / commit message / wiki page) carrying an HTML comment whose body issues an agent-directed imperative. Layer 2 fires on an HTML comment that names an AI reviewer and then directs it. Layer 3 covers the CSS-hidden container variant (display:none, font-size:0, white-on-white). Layer 4 is the missing-spotlighting discriminator proper: an MCP text-content envelope whose payload begins as a raw JSON.stringify serialization — i.e. it did NOT pass through a spotlighting wrapper — and carries a hidden-comment imperative. Layer 5 is a secondary skill-content signal for tooling that instructs an agent to PLANT such hidden content. CWE-1385-adjacent trust boundary failure; primarily CWE-20 (Improper Input Validation) on the tool-output channel and CWE-441 (Unintended Proxy / Confused Deputy).
author: ATR Community
date: 2026/07/28
schema_version: '0.1'
detection_tier: pattern
maturity: test
severity: high
references:
  owasp_llm:
    - LLM01:2025
    - LLM05:2025
  owasp_agentic:
    - ASI01:2026
    - ASI06:2026
  mitre_atlas:
    - 'AML.T0051.001 - LLM Prompt Injection: Indirect'
    - AML.T0057 - LLM Data Leakage
  mitre_attack:
    - T1195.002 - Compromise Software Supply Chain
    - T1078 - Valid Accounts
  external:
    - https://thehackernews.com/2026/07/microsoft-azure-devops-mcp-flaw-lets.html
    - https://github.com/microsoft/azure-devops-mcp/pull/1062
    - https://arxiv.org/abs/2403.14720
metadata_provenance:
  owasp_llm: human-reviewed
  owasp_agentic: human-reviewed
  mitre_atlas: human-reviewed
  external: human-reviewed
compliance:
  eu_ai_act:
    - article: '15'
      context: Article 15 (accuracy, robustness, cybersecurity) requires resilience against attempts to manipulate system behaviour through inputs. An MCP server that returns third-party pull-request or wiki text into model context without a spotlighting boundary provides no separation between data and instructions, so any attacker with write access to a PR description can steer an agent holding the reviewer's credentials. This rule supplies the runtime detection evidence for that control.
      strength: primary
    - article: '14'
      context: Article 14 (human oversight) is defeated specifically here because the injected instruction is invisible in the vendor web UI while being returned verbatim by the REST API. The reviewing human cannot see what the agent is acting on, so oversight is nominal rather than effective; detecting hidden-instruction carriers in tool output restores the reviewer's ability to see what entered the context.
      strength: primary
  nist_ai_rmf:
    - function: Map
      subcategory: MP.2.2
      context: 'MP.2.2 requires documenting how the AI system processes inputs from external sources. This rule operationalises the mapping boundary for MCP tool responses: content authored by untrusted third parties (PR descriptions, wiki pages, build logs) must be enumerated as an untrusted-input surface and marked as such before it reaches the model.'
      strength: primary
    - function: Measure
      subcategory: MS.2.7
      context: MS.2.7 (security and resilience evaluated and documented) is supported by measuring how often external content re-enters agent context without a spotlighting delimiter, which is the precise defect that separated the patched wiki/build-log tool paths from the unpatched pull-request path in the disclosed case.
      strength: primary
    - function: Manage
      subcategory: MG.2.3
      context: 'MG.2.3 (risk treatment selected and tracked) is supported by treating unspotlighted external content as a blockable condition: the treatment is to wrap every externally-authored tool result in nonce-delimited markers and to require fresh human confirmation for any cross-project action an agent proposes after ingesting such content.'
      strength: secondary
  iso_42001:
    - clause: '8.1'
      context: 'Clause 8.1 (operational planning and control, including control of externally-provided processes) is directly engaged: the MCP server is an externally-provided component whose output crosses into the AI system''s instruction channel. Consistent application of the content-safety wrapper across ALL tools returning third-party text is the operational control this rule verifies, since the disclosed failure was inconsistent application, not absent capability.'
      strength: primary
    - clause: '8.4'
      context: 'Clause 8.4 (AI system impact assessment) must account for the credential-inheritance impact: the agent acts with the reviewer''s authorisation, so an injected instruction reaches projects the attacker was never granted, converting a comment-write permission into read access across the tenant.'
      strength: secondary
tags:
  category: tool-poisoning
  subcategory: unspotlighted-external-content
  scan_target: both
  confidence: medium
agent_source:
  type: mcp_exchange
  framework:
    - any
  provider:
    - any
detection:
  condition: any
  method: pattern
  false_positives:
    - Documentation, changelogs and README content returned by a docs/wiki MCP tool that legitimately contains HTML comments used for authoring directives (for example a comment reading 'do not edit, generated file') — these carry no second-person imperative aimed at an agent.
    - Static-site and Markdown tooling output containing template comments such as <!-- prettier-ignore -->, <!-- markdownlint-disable -->, <!-- more --> or Jekyll/Hugo front-matter comments.
    - 'Security research write-ups, disclosure posts and conference material about this very technique returned by a web-fetch or wiki MCP tool: prose describing that an attacker hides instructions in a PR description is third-person reportage, not a live payload. Layer 5 is bounded to imperative authoring verbs to keep this class out, but a quoted PoC snippet inside a research article WILL fire Layers 1-4 by design, because a quoted payload re-entering agent context is itself the risk.'
    - 'Content that IS correctly spotlighted but still carries an embedded imperative: Layers 1-3 fire on the carrier regardless of the wrapper. This is intentional (spotlighting mitigates but does not neutralise an injection attempt) but means those layers do not, on their own, prove that spotlighting was missing. Only Layer 4 asserts the raw-serialization path.'
    - Templated PR/issue bodies generated by bots (dependabot, release-please) that embed HTML comments holding machine-readable metadata; these lack agent-directed imperative verbs.
  conditions:
    - field: tool_response
      operator: regex
      value: (?i)"(?:description|body|content|pagecontent|comment|commenttext|sourcecommenttext|reviewcomment|markdown|summary|notes|commitmessage|title)"\s*:\s*"[\s\S]{0,900}[\s\S]{0,900}<!--(?:[^-]|-[^-]|--[^>]){0,400}(?:ignore\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above)|disregard\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above)|new\s+instructions?\b|\[system\]|<system>|you\s+(?:must|should|shall|need\s+to)\s+(?:now\s+|also\s+|first\s+)?(?:call|invoke|run|execute|fetch|read|open|post|send|create|trigger|list|clone)|do\s+not\s+(?:tell|inform|mention|reveal|notify|report|alert)\s+(?:the\s+)?(?:user|reviewer|human|operator))
      description: Forge external-content field (PR/issue description, comment, commit message, wiki page) whose value contains an HTML comment carrying an agent-directed imperative — the Azure DevOps repo_get_pull_request_by_id payload shape, generalised across forges.
    - field: tool_response
      operator: regex
      value: (?i)<!--(?:[^-]|-[^-]|--[^>]){0,300}\b(?:ai\s+(?:agent|assistant|reviewer|review)|llm|assistant|copilot|claude|chatgpt|gemini|cursor|codex|code\s+review(?:er)?\s+(?:agent|bot|assistant)|review(?:ing)?\s+agent|automated\s+reviewer)\b(?:[^-]|-[^-]|--[^>]){0,200}\b(?:you\s+(?:must|should|shall|need\s+to|are\s+required\s+to)|please\s+(?:also\s+)?(?:call|run|fetch|read|send|post|create|invoke|retrieve)|ignore\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above)|do\s+not\s+(?:tell|inform|mention|reveal)|instead\s+of\s+(?:reviewing|summarising|summarizing))\b
      description: HTML comment inside a tool response that addresses an AI reviewer by name and issues a directive — hidden instruction aimed squarely at the ingesting agent.
    - field: tool_response
      operator: regex
      value: (?i)<(?:span|div|p|font|section|details|td)\b[^>]{0,200}(?:display\s*:\s*none|visibility\s*:\s*hidden|font-size\s*:\s*0|opacity\s*:\s*0|color\s*:\s*#?(?:fff\b|ffffff\b|white\b))[^>]{0,200}>(?:[^<]|<[^/]){0,300}\b(?:ignore\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above)|you\s+(?:must|should|shall)\s+(?:now\s+|also\s+|first\s+)?(?:call|run|execute|fetch|read|post|send|create)|do\s+not\s+(?:tell|inform|mention|reveal)\s+(?:the\s+)?(?:user|reviewer)|new\s+instructions?\b)
      description: CSS-hidden or hidden-attribute container in tool output followed by an agent-directed imperative — the white-on-white / display:none variant of the same hidden-instruction carrier.
    - field: tool_response
      operator: regex
      value: (?i)"type"\s*:\s*"text"\s*,\s*"text"\s*:\s*"(?:\\[nrt]|\s){0,20}[\{\[][\s\S]{0,900}[\s\S]{0,900}<!--(?:[^-]|-[^-]|--[^>]){0,400}(?:ignore\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above)|you\s+(?:must|should|shall)\s+(?:now\s+|also\s+|first\s+)?(?:call|run|execute|fetch|read|post|send|create)|do\s+not\s+(?:tell|inform|mention|reveal)\s+(?:the\s+)?(?:user|reviewer)|new\s+instructions?\b|\[system\])
      description: MCP text-content envelope whose payload starts as raw JSON serialization rather than a spotlighting delimiter, and which carries a hidden-comment imperative — external content reaching model context with the content-safety wrapper skipped.
    - field: content
      operator: regex
      value: (?i)\b(?:embed|insert|inject|append|plant|hide|conceal|place)\b[^\n]{0,80}\b(?:hidden|invisible|html\s+comment|<!--)[^\n]{0,140}\b(?:pull\s+request|pr\s+description|merge\s+request|issue\s+(?:body|description)|commit\s+message|wiki\s+page)\b
      description: Skill or tool content instructing the agent to plant hidden/invisible markup into a pull-request, issue, commit or wiki field — the authoring side of this technique. Secondary signal only; never sufficient on its own to conclude a live attack.
response:
  actions:
    - alert
    - block_tool
    - require_human_review
  auto_response_threshold: high
  message_template: |
    [ATR-2026-02403] HIGH: an MCP tool response carried third-party content with instructions hidden from the human reviewer (HTML comment, CSS-hidden block, or an unwrapped raw JSON payload), and that content is about to enter model context as if it were trusted. This is the confused-deputy pattern disclosed against the Azure DevOps MCP server: the agent holds the reviewer's credentials, so an instruction planted by anyone who can edit a PR description reaches projects the attacker cannot. Remediation: route EVERY tool that returns externally-authored text through the same spotlighting / nonce-delimiting helper used by the already-hardened tools in that server — partial coverage is the vulnerability; strip or neutralise HTML comments and hidden-CSS containers before serialization; and require fresh human confirmation for any cross-project read or any write-back (PR comment, work item, pipeline trigger) that the agent proposes after ingesting external content.
  notify:
    - security_team
confidence: 72
_authoring_notes:
  containment_not_proximity: |
    Revision 1 bridged from the carrier to the imperative with an unbounded [\s\S]{0,N} span, which asserted only that the two were NEAR each other — not that the imperative was INSIDE the carrier. That gap made the rule fire on ordinary content: a benign <!-- prettier-ignore --> or issue-template comment followed within 400 characters by a perfectly normal, fully visible "you must run the bootstrap script" sentence matched Layer 1, and a hidden tracking-pixel <div> followed by visible reviewer prose matched Layer 3. Six of six realistic README / PR / wiki bodies false-positived. That class is common enough in real forge traffic to make the rule undeployable, and it was invisible to the standard gates because the rule's own true_negatives tested the two halves separately (a comment with no imperative, an imperative with no comment) but never the combination. Revision 2 asserts containment positively, which RE2 can express without the negative lookahead the natural formulation would need:
      * inside an HTML comment — (?:[^-]|-[^-]|--[^>]){0,N} matches any run that
        cannot contain "-->", so the imperative must precede the comment's close.
      * inside a hidden element — (?:[^<]|<[^/]){0,N} matches any run that cannot
        contain a closing tag, so the imperative must precede the element's close.
    Both are single bounded repeats over a mutually-exclusive alternation (each branch is distinguished by its first one or two characters), so there is no nested quantifier and the engine ReDoS gate accepts them unchanged. Measured in Go: all five patterns compile in under 600 microseconds into 625-7,277 instructions, so the bounded repeats stay well inside RE2's program-size cap. The cost is the closing-tag bypass recorded in evasion_tests, and it is worth stating precisely because the obvious guess is wrong: a nested OPENING tag does NOT defeat Layer 3 (there is a true_positive pinning that), only a closing tag ahead of the imperative does. The benefit is that the rule stops alerting on everyday human instructions, which is the right trade for a rule whose output lands in a human reviewer's queue.
  re2_portability: |
    Every pattern in this rule is RE2-compatible by construction and was checked against the constructs RE2 rejects: no lookahead (?= (?!, no lookbehind (?<= (?<!, no backreferences, no atomic groups (?>, no possessive quantifiers, no named groups, and no \u / \x{} Unicode escapes. The missing-spotlighting condition is expressed POSITIVELY (payload begins with { or [, i.e. raw JSON.stringify output) precisely because the natural formulation — "text field not followed by a nonce delimiter" — would need a negative lookahead and would not survive export to Go or Rust engines. All spans are bounded ([\s\S]{0,N}, [^>]{0,N}) with no nested quantifiers, so the patterns also pass the engine ReDoS gate. RE2 additionally caps any single repeat count at 1000: {0,1500} and {0,3000} are accepted by JavaScript but are a hard compile error in Go/Rust ("invalid repeat count"). Where this rule needs a wider window it chains two bounded spans ([\s\S]{0,900}[\s\S]{0,900}) instead of raising one count past the cap. Verified by compiling all five patterns with Go regexp (RE2) and re-running the full test-case set on both engines.
  generalization_note: |
    The rule detects the tool RESPONSE payload, never the vendor and never the reporting about the vendor. No layer matches the strings "Azure DevOps", "Manifold", "repo_get_pull_request_by_id" or any news-article phrasing, so it does not degrade into a reportage detector. Layers 1-4 all require an attacker-controlled carrier (HTML comment or hidden container) co-occurring with an agent-directed imperative inside real serialized tool output; Layer 5 is explicitly secondary and requires an imperative authoring verb so that third-person descriptions of the technique do not fire.
